From 1a5dda6fe3939e23c4f5534a50ebb105d2d9ba08 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 18:04:36 -0800 Subject: [PATCH 01/35] improvement(canvas): add multi-block select, add batch handle, enabled, and edge operations --- apps/sim/app/(landing)/privacy/page.tsx | 2 +- apps/sim/app/_styles/globals.css | 32 + .../w/[workflowId]/components/chat/chat.tsx | 2 +- .../toolbar/components/drag-preview.ts | 5 +- .../components/terminal/terminal.tsx | 6 +- .../components/action-bar/action-bar.tsx | 8 +- .../hooks/{use-float => float}/index.ts | 0 .../use-float-boundary-sync.ts | 0 .../{use-float => float}/use-float-drag.ts | 0 .../{use-float => float}/use-float-resize.ts | 0 .../w/[workflowId]/hooks/index.ts | 10 +- .../w/[workflowId]/hooks/use-block-visual.ts | 7 +- .../w/[workflowId]/utils/block-ring-utils.ts | 16 +- .../utils/workflow-canvas-helpers.ts | 141 +++++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 367 ++++++----- .../emails/components/email-footer.tsx | 2 +- apps/sim/hooks/use-collaborative-workflow.ts | 223 +++++-- apps/sim/hooks/use-undo-redo.ts | 577 ++++++++++++------ apps/sim/socket/database/operations.ts | 119 ++++ apps/sim/socket/handlers/operations.ts | 116 ++++ apps/sim/socket/middleware/permissions.ts | 8 + apps/sim/socket/validation/schemas.ts | 55 +- apps/sim/stores/undo-redo/store.test.ts | 2 +- apps/sim/stores/undo-redo/store.ts | 104 ++-- apps/sim/stores/undo-redo/types.ts | 55 +- apps/sim/stores/undo-redo/utils.ts | 103 +++- packages/testing/src/factories/index.ts | 2 +- .../src/factories/undo-redo.factory.ts | 28 +- 28 files changed, 1432 insertions(+), 558 deletions(-) rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/{use-float => float}/index.ts (100%) rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/{use-float => float}/use-float-boundary-sync.ts (100%) rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/{use-float => float}/use-float-drag.ts (100%) rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/{use-float => float}/use-float-resize.ts (100%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts diff --git a/apps/sim/app/(landing)/privacy/page.tsx b/apps/sim/app/(landing)/privacy/page.tsx index 7f6a0ee7d6..a32e2b980c 100644 --- a/apps/sim/app/(landing)/privacy/page.tsx +++ b/apps/sim/app/(landing)/privacy/page.tsx @@ -767,7 +767,7 @@ export default function PrivacyPolicy() { privacy@sim.ai -
  • Mailing Address: Sim, 80 Langton St, San Francisco, CA 94133, USA
  • +
  • Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA
  • We will respond to your request within a reasonable timeframe.

    diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index b94f9a2e58..4123df565c 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -42,6 +42,38 @@ animation: dash-animation 1.5s linear infinite !important; } +/** + * React Flow selection box styling + * Uses brand-secondary color for selection highlighting + */ +.react-flow__selection { + background: rgba(51, 180, 255, 0.08) !important; + border: 1px solid var(--brand-secondary) !important; +} + +.react-flow__nodesselection-rect { + background: transparent !important; + border: none !important; +} + +/** + * Selected node ring indicator + * Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40) + */ +.react-flow__node.selected > div > div { + position: relative; +} + +.react-flow__node.selected > div > div::after { + content: ""; + position: absolute; + inset: 0; + z-index: 40; + border-radius: 8px; + box-shadow: 0 0 0 1.75px var(--brand-secondary); + pointer-events: none; +} + /** * Color tokens - single source of truth for all colors * Light mode: Warm theme diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 5af836d845..d46eab0f2d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -45,7 +45,7 @@ import { useFloatBoundarySync, useFloatDrag, useFloatResize, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float' +} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import type { BlockLog, ExecutionResult } from '@/executor/types' import { getChatPosition, useChatStore } from '@/stores/chat/store' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts index 1ea9bf4e86..e79663e5c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts @@ -32,7 +32,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement { z-index: 9999; ` - // Create icon container const iconContainer = document.createElement('div') iconContainer.style.cssText = ` width: 24px; @@ -45,7 +44,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement { flex-shrink: 0; ` - // Clone the actual icon if provided if (info.iconElement) { const clonedIcon = info.iconElement.cloneNode(true) as HTMLElement clonedIcon.style.width = '16px' @@ -55,11 +53,10 @@ export function createDragPreview(info: DragItemInfo): HTMLElement { iconContainer.appendChild(clonedIcon) } - // Create text element const text = document.createElement('span') text.textContent = info.name text.style.cssText = ` - color: #FFFFFF; + color: var(--text-primary); font-size: 16px; font-weight: 500; white-space: nowrap; diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index b63ce2d3b4..be94da2619 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -1489,9 +1489,7 @@ export function Terminal() { variant='ghost' className={clsx( 'px-[8px] py-[6px] text-[12px]', - !showInput && - hasInputData && - '!text-[var(--text-primary)] dark:!text-[var(--text-primary)]' + !showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]' )} onClick={(e) => { e.stopPropagation() @@ -1509,7 +1507,7 @@ export function Terminal() { variant='ghost' className={clsx( 'px-[8px] py-[6px] text-[12px]', - showInput && '!text-[var(--text-primary)]' + showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]' )} onClick={(e) => { e.stopPropagation() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx index 3faa5498ac..43b846ea39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx @@ -34,8 +34,8 @@ export const ActionBar = memo( const { collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, - collaborativeToggleBlockEnabled, - collaborativeToggleBlockHandles, + collaborativeBatchToggleBlockEnabled, + collaborativeBatchToggleBlockHandles, } = useCollaborativeWorkflow() const { activeWorkflowId } = useWorkflowRegistry() const blocks = useWorkflowStore((state) => state.blocks) @@ -121,7 +121,7 @@ export const ActionBar = memo( onClick={(e) => { e.stopPropagation() if (!disabled) { - collaborativeToggleBlockEnabled(blockId) + collaborativeBatchToggleBlockEnabled([blockId]) } }} className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]' @@ -192,7 +192,7 @@ export const ActionBar = memo( onClick={(e) => { e.stopPropagation() if (!disabled) { - collaborativeToggleBlockHandles(blockId) + collaborativeBatchToggleBlockHandles([blockId]) } }} className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/index.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-boundary-sync.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-boundary-sync.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-boundary-sync.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-boundary-sync.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-drag.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-drag.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-drag.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-drag.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-resize.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-resize.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-resize.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts index 18747c43d3..65bd3d4e49 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts @@ -1,8 +1,16 @@ +export { + clearDragHighlights, + computeClampedPositionUpdates, + getClampedPositionForNode, + isInEditableElement, + selectNodesDeferred, + validateTriggerPaste, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers' +export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './float' export { useAutoLayout } from './use-auto-layout' export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions' export { useBlockVisual } from './use-block-visual' export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow' -export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './use-float' export { useNodeUtilities } from './use-node-utilities' export { usePreventZoom } from './use-prevent-zoom' export { useScrollManagement } from './use-scroll-management' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts index 7fd80df207..82795fc99e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts @@ -21,7 +21,7 @@ interface UseBlockVisualProps { /** * Provides visual state and interaction handlers for workflow blocks. - * Computes ring styling based on execution, focus, diff, and run path states. + * Computes ring styling based on execution, diff, deletion, and run path states. * In preview mode, all interactive and execution-related visual states are disabled. * * @param props - The hook properties @@ -46,8 +46,6 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId) const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId) - const currentBlockId = usePanelEditorStore((state) => state.currentBlockId) - const isFocused = isPreview ? false : currentBlockId === blockId const handleClick = useCallback(() => { if (!isPreview) { @@ -60,12 +58,11 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis getBlockRingStyles({ isActive, isPending: isPreview ? false : isPending, - isFocused, isDeletedBlock: isPreview ? false : isDeletedBlock, diffStatus: isPreview ? undefined : diffStatus, runPathStatus, }), - [isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus, isPreview] + [isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview] ) return { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts index 1b532c694f..e6f081b22f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts @@ -7,7 +7,6 @@ export type BlockRunPathStatus = 'success' | 'error' | undefined export interface BlockRingOptions { isActive: boolean isPending: boolean - isFocused: boolean isDeletedBlock: boolean diffStatus: BlockDiffStatus runPathStatus: BlockRunPathStatus @@ -15,18 +14,17 @@ export interface BlockRingOptions { /** * Derives visual ring visibility and class names for workflow blocks - * based on execution, focus, diff, deletion, and run-path states. + * based on execution, diff, deletion, and run-path states. */ export function getBlockRingStyles(options: BlockRingOptions): { hasRing: boolean ringClassName: string } { - const { isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus } = options + const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus } = options const hasRing = isActive || isPending || - isFocused || diffStatus === 'new' || diffStatus === 'edited' || isDeletedBlock || @@ -39,34 +37,28 @@ export function getBlockRingStyles(options: BlockRingOptions): { !isActive && hasRing && 'ring-[1.75px]', // Pending state: warning ring !isActive && isPending && 'ring-[var(--warning)]', - // Focused (selected) state: brand ring - !isActive && !isPending && isFocused && 'ring-[var(--brand-secondary)]', - // Deleted state (highest priority after active/pending/focused) - !isActive && !isPending && !isFocused && isDeletedBlock && 'ring-[var(--text-error)]', + // Deleted state (highest priority after active/pending) + !isActive && !isPending && isDeletedBlock && 'ring-[var(--text-error)]', // Diff states !isActive && !isPending && - !isFocused && !isDeletedBlock && diffStatus === 'new' && 'ring-[var(--brand-tertiary)]', !isActive && !isPending && - !isFocused && !isDeletedBlock && diffStatus === 'edited' && 'ring-[var(--warning)]', // Run path states (lowest priority - only show if no other states active) !isActive && !isPending && - !isFocused && !isDeletedBlock && !diffStatus && runPathStatus === 'success' && 'ring-[var(--border-success)]', !isActive && !isPending && - !isFocused && !isDeletedBlock && !diffStatus && runPathStatus === 'error' && diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts new file mode 100644 index 0000000000..634aedb326 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -0,0 +1,141 @@ +import type { Node } from 'reactflow' +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' +import { TriggerUtils } from '@/lib/workflows/triggers/triggers' +import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' +import type { BlockState } from '@/stores/workflows/workflow/types' + +/** + * Checks if the currently focused element is an editable input. + * Returns true if the user is typing in an input, textarea, or contenteditable element. + */ +export function isInEditableElement(): boolean { + const activeElement = document.activeElement + return ( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement?.hasAttribute('contenteditable') === true + ) +} + +interface TriggerValidationResult { + isValid: boolean + message?: string +} + +/** + * Validates that pasting/duplicating trigger blocks won't violate constraints. + * Returns validation result with error message if invalid. + */ +export function validateTriggerPaste( + blocksToAdd: Array<{ type: string }>, + existingBlocks: Record, + action: 'paste' | 'duplicate' +): TriggerValidationResult { + for (const block of blocksToAdd) { + if (TriggerUtils.isAnyTriggerType(block.type)) { + const issue = TriggerUtils.getTriggerAdditionIssue(existingBlocks, block.type) + if (issue) { + const actionText = action === 'paste' ? 'paste' : 'duplicate' + const message = + issue.issue === 'legacy' + ? `Cannot ${actionText} trigger blocks when a legacy Start block exists.` + : `A workflow can only have one ${issue.triggerName} trigger block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : 'Cannot duplicate.'}` + return { isValid: false, message } + } + } + } + return { isValid: true } +} + +/** + * Clears drag highlight classes and resets cursor state. + * Used when drag operations end or are cancelled. + */ +export function clearDragHighlights(): void { + document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => { + el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') + }) + document.body.style.cursor = '' +} + +/** + * Selects nodes by their IDs after paste/duplicate operations. + * Defers selection to next animation frame to allow displayNodes to sync from store first. + * This is necessary because the component uses controlled state (nodes={displayNodes}) + * and newly added blocks need time to propagate through the store → derivedNodes → displayNodes cycle. + */ +export function selectNodesDeferred( + nodeIds: string[], + setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void +): void { + const idsSet = new Set(nodeIds) + requestAnimationFrame(() => { + setDisplayNodes((nodes) => + nodes.map((node) => ({ + ...node, + selected: idsSet.has(node.id), + })) + ) + }) +} + +interface BlockData { + height?: number + data?: { + parentId?: string + width?: number + height?: number + } +} + +/** + * Calculates the final position for a node, clamping it to parent container if needed. + * Returns the clamped position suitable for persistence. + */ +export function getClampedPositionForNode( + nodeId: string, + nodePosition: { x: number; y: number }, + blocks: Record, + allNodes: Node[] +): { x: number; y: number } { + const currentBlock = blocks[nodeId] + const currentParentId = currentBlock?.data?.parentId + + if (!currentParentId) { + return nodePosition + } + + const parentNode = allNodes.find((n) => n.id === currentParentId) + if (!parentNode) { + return nodePosition + } + + const containerDimensions = { + width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + const blockDimensions = { + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max( + currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT, + BLOCK_DIMENSIONS.MIN_HEIGHT + ), + } + + return clampPositionToContainer(nodePosition, containerDimensions, blockDimensions) +} + +/** + * Computes position updates for multiple nodes, clamping each to its parent container. + * Used for batch position updates after multi-node drag or selection drag. + */ +export function computeClampedPositionUpdates( + nodes: Node[], + blocks: Record, + allNodes: Node[] +): Array<{ id: string; position: { x: number; y: number } }> { + return nodes.map((node) => ({ + id: node.id, + position: getClampedPositionForNode(node.id, node.position, blocks, allNodes), + })) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 7b4b56ff47..2462672ea3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -11,6 +11,7 @@ import ReactFlow, { type NodeChange, type NodeTypes, ReactFlowProvider, + SelectionMode, useReactFlow, } from 'reactflow' import 'reactflow/dist/style.css' @@ -42,9 +43,15 @@ import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' import { + clearDragHighlights, + computeClampedPositionUpdates, + getClampedPositionForNode, + isInEditableElement, + selectNodesDeferred, useAutoLayout, useCurrentWorkflow, useNodeUtilities, + validateTriggerPaste, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu' import { @@ -180,11 +187,12 @@ const reactFlowStyles = [ const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const const reactFlowProOptions = { hideAttribution: true } as const -interface SelectedEdgeInfo { - id: string - parentLoopId?: string - contextId?: string -} +/** + * Map from edge contextId to edge id. + * Context IDs include parent loop info for edges inside loops. + * The actual edge ID is stored as the value for deletion operations. + */ +type SelectedEdgesMap = Map interface BlockData { id: string @@ -200,7 +208,7 @@ interface BlockData { const WorkflowContent = React.memo(() => { const [isCanvasReady, setIsCanvasReady] = useState(false) const [potentialParentId, setPotentialParentId] = useState(null) - const [selectedEdgeInfo, setSelectedEdgeInfo] = useState(null) + const [selectedEdges, setSelectedEdges] = useState(new Map()) const [isShiftPressed, setIsShiftPressed] = useState(false) const [isSelectionDragActive, setIsSelectionDragActive] = useState(false) const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false) @@ -280,7 +288,7 @@ const WorkflowContent = React.memo(() => { useStreamCleanup(copilotCleanup) - const { blocks, edges, isDiffMode, lastSaved } = currentWorkflow + const { blocks, edges, lastSaved } = currentWorkflow const isWorkflowReady = useMemo( () => @@ -343,6 +351,11 @@ 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) + /** Stores start positions for multi-node drag undo/redo recording. */ + const multiNodeDragStartRef = useRef>( + new Map() + ) + /** Re-applies diff markers when blocks change after socket rehydration. */ const blocksRef = useRef(blocks) useEffect(() => { @@ -431,12 +444,13 @@ const WorkflowContent = React.memo(() => { const { collaborativeAddEdge: addEdge, collaborativeRemoveEdge: removeEdge, + collaborativeBatchRemoveEdges, collaborativeBatchUpdatePositions, collaborativeUpdateParentId: updateParentId, collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, - collaborativeToggleBlockEnabled, - collaborativeToggleBlockHandles, + collaborativeBatchToggleBlockEnabled, + collaborativeBatchToggleBlockHandles, undo, redo, } = useCollaborativeWorkflow() @@ -636,22 +650,14 @@ const WorkflowContent = React.memo(() => { } = pasteData const pastedBlocksArray = Object.values(pastedBlocks) - for (const block of pastedBlocksArray) { - if (TriggerUtils.isAnyTriggerType(block.type)) { - const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type) - if (issue) { - const message = - issue.issue === 'legacy' - ? 'Cannot paste trigger blocks when a legacy Start block exists.' - : `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before pasting.` - addNotification({ - level: 'error', - message, - workflowId: activeWorkflowId || undefined, - }) - return - } - } + const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'paste') + if (!validation.isValid) { + addNotification({ + level: 'error', + message: validation.message!, + workflowId: activeWorkflowId || undefined, + }) + return } collaborativeBatchAddBlocks( @@ -661,6 +667,11 @@ const WorkflowContent = React.memo(() => { pastedParallels, pastedSubBlockValues ) + + selectNodesDeferred( + pastedBlocksArray.map((b) => b.id), + setDisplayNodes + ) }, [ hasClipboard, clipboard, @@ -687,22 +698,14 @@ const WorkflowContent = React.memo(() => { } = pasteData const pastedBlocksArray = Object.values(pastedBlocks) - for (const block of pastedBlocksArray) { - if (TriggerUtils.isAnyTriggerType(block.type)) { - const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type) - if (issue) { - const message = - issue.issue === 'legacy' - ? 'Cannot duplicate trigger blocks when a legacy Start block exists.' - : `A workflow can only have one ${issue.triggerName} trigger block. Cannot duplicate.` - addNotification({ - level: 'error', - message, - workflowId: activeWorkflowId || undefined, - }) - return - } - } + const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'duplicate') + if (!validation.isValid) { + addNotification({ + level: 'error', + message: validation.message!, + workflowId: activeWorkflowId || undefined, + }) + return } collaborativeBatchAddBlocks( @@ -712,6 +715,11 @@ const WorkflowContent = React.memo(() => { pastedParallels, pastedSubBlockValues ) + + selectNodesDeferred( + pastedBlocksArray.map((b) => b.id), + setDisplayNodes + ) }, [ contextMenuBlocks, copyBlocks, @@ -728,16 +736,14 @@ const WorkflowContent = React.memo(() => { }, [contextMenuBlocks, collaborativeBatchRemoveBlocks]) const handleContextToggleEnabled = useCallback(() => { - contextMenuBlocks.forEach((block) => { - collaborativeToggleBlockEnabled(block.id) - }) - }, [contextMenuBlocks, collaborativeToggleBlockEnabled]) + const blockIds = contextMenuBlocks.map((block) => block.id) + collaborativeBatchToggleBlockEnabled(blockIds) + }, [contextMenuBlocks, collaborativeBatchToggleBlockEnabled]) const handleContextToggleHandles = useCallback(() => { - contextMenuBlocks.forEach((block) => { - collaborativeToggleBlockHandles(block.id) - }) - }, [contextMenuBlocks, collaborativeToggleBlockHandles]) + const blockIds = contextMenuBlocks.map((block) => block.id) + collaborativeBatchToggleBlockHandles(blockIds) + }, [contextMenuBlocks, collaborativeBatchToggleBlockHandles]) const handleContextRemoveFromSubflow = useCallback(() => { contextMenuBlocks.forEach((block) => { @@ -788,13 +794,7 @@ const WorkflowContent = React.memo(() => { let cleanup: (() => void) | null = null const handleKeyDown = (event: KeyboardEvent) => { - const activeElement = document.activeElement - const isEditableElement = - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement?.hasAttribute('contenteditable') - - if (isEditableElement) { + if (isInEditableElement()) { event.stopPropagation() return } @@ -840,22 +840,14 @@ const WorkflowContent = React.memo(() => { const pasteData = preparePasteData(pasteOffset) if (pasteData) { const pastedBlocks = Object.values(pasteData.blocks) - for (const block of pastedBlocks) { - if (TriggerUtils.isAnyTriggerType(block.type)) { - const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type) - if (issue) { - const message = - issue.issue === 'legacy' - ? 'Cannot paste trigger blocks when a legacy Start block exists.' - : `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before pasting.` - addNotification({ - level: 'error', - message, - workflowId: activeWorkflowId || undefined, - }) - return - } - } + const validation = validateTriggerPaste(pastedBlocks, blocks, 'paste') + if (!validation.isValid) { + addNotification({ + level: 'error', + message: validation.message!, + workflowId: activeWorkflowId || undefined, + }) + return } collaborativeBatchAddBlocks( @@ -865,6 +857,11 @@ const WorkflowContent = React.memo(() => { pasteData.parallels, pasteData.subBlockValues ) + + selectNodesDeferred( + pastedBlocks.map((b) => b.id), + setDisplayNodes + ) } } } @@ -1168,10 +1165,7 @@ const WorkflowContent = React.memo(() => { try { const containerInfo = isPointInLoopNode(position) - document - .querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over') - .forEach((el) => el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')) - document.body.style.cursor = '' + clearDragHighlights() document.body.classList.remove('sim-drag-subflow') if (data.type === 'loop' || data.type === 'parallel') { @@ -1599,11 +1593,7 @@ const WorkflowContent = React.memo(() => { const containerInfo = isPointInLoopNode(position) // Clear any previous highlighting - document - .querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over') - .forEach((el) => { - el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - }) + clearDragHighlights() // Highlight container if hovering over it and not dragging a subflow // Subflow drag is marked by body class flag set by toolbar @@ -1803,7 +1793,7 @@ const WorkflowContent = React.memo(() => { const nodeArray: Node[] = [] // Add block nodes - Object.entries(blocks).forEach(([blockId, block]) => { + Object.entries(blocks).forEach(([, block]) => { if (!block || !block.type || !block.name) { return } @@ -1880,8 +1870,11 @@ const WorkflowContent = React.memo(() => { }, // Include dynamic dimensions for container resizing calculations (must match rendered size) // Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions + // Use estimated dimensions for blocks without measured height to ensure selection bounds are correct width: BLOCK_DIMENSIONS.FIXED_WIDTH, - height: Math.max(block.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT), + height: block.height + ? Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT) + : estimateBlockDimensions(block.type).height, }) }) @@ -1933,7 +1926,14 @@ const WorkflowContent = React.memo(() => { }, [isShiftPressed]) useEffect(() => { - setDisplayNodes(derivedNodes) + // Preserve selection state when syncing from derivedNodes + setDisplayNodes((currentNodes) => { + const selectedIds = new Set(currentNodes.filter((n) => n.selected).map((n) => n.id)) + return derivedNodes.map((node) => ({ + ...node, + selected: selectedIds.has(node.id), + })) + }) }, [derivedNodes]) /** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */ @@ -2247,12 +2247,8 @@ const WorkflowContent = React.memo(() => { if (isStarterBlock) { // If it's a starter block, remove any highlighting and don't allow it to be dragged into containers if (potentialParentId) { - const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`) - if (prevElement) { - prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - } + clearDragHighlights() setPotentialParentId(null) - document.body.style.cursor = '' } return // Exit early - don't process any container intersections for starter blocks } @@ -2264,12 +2260,8 @@ const WorkflowContent = React.memo(() => { if (node.type === 'subflowNode') { // Clear any highlighting for subflow nodes if (potentialParentId) { - const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`) - if (prevElement) { - prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - } + clearDragHighlights() setPotentialParentId(null) - document.body.style.cursor = '' } return // Exit early - subflows cannot be placed inside other subflows } @@ -2370,12 +2362,8 @@ const WorkflowContent = React.memo(() => { } else { // Remove highlighting if no longer over a container if (potentialParentId) { - const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`) - if (prevElement) { - prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - } + clearDragHighlights() setPotentialParentId(null) - document.body.style.cursor = '' } } }, @@ -2402,49 +2390,48 @@ const WorkflowContent = React.memo(() => { y: node.position.y, parentId: currentParentId, }) + + // Capture all selected nodes' positions for multi-node undo/redo + const allNodes = getNodes() + const selectedNodes = allNodes.filter((n) => n.selected) + multiNodeDragStartRef.current.clear() + selectedNodes.forEach((n) => { + multiNodeDragStartRef.current.set(n.id, { + x: n.position.x, + y: n.position.y, + parentId: blocks[n.id]?.data?.parentId, + }) + }) }, - [blocks, setDragStartPosition] + [blocks, setDragStartPosition, getNodes] ) /** Handles node drag stop to establish parent-child relationships. */ const onNodeDragStop = useCallback( (_event: React.MouseEvent, node: any) => { - // Clear UI effects - document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => { - el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - }) - document.body.style.cursor = '' + clearDragHighlights() - // Get the block's current parent (if any) - const currentBlock = blocks[node.id] - const currentParentId = currentBlock?.data?.parentId + // Get all selected nodes to update their positions too + const allNodes = getNodes() + const selectedNodes = allNodes.filter((n) => n.selected) - // Calculate position - clamp if inside a container - let finalPosition = node.position - if (currentParentId) { - // Block is inside a container - clamp position to keep it fully inside - const parentNode = getNodes().find((n) => n.id === currentParentId) - if (parentNode) { - const containerDimensions = { - width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - } - const blockDimensions = { - width: BLOCK_DIMENSIONS.FIXED_WIDTH, - height: Math.max( - currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT, - BLOCK_DIMENSIONS.MIN_HEIGHT - ), - } + // If multiple nodes are selected, update all their positions + if (selectedNodes.length > 1) { + const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes) + collaborativeBatchUpdatePositions(positionUpdates, { + previousPositions: multiNodeDragStartRef.current, + }) - finalPosition = clampPositionToContainer( - node.position, - containerDimensions, - blockDimensions - ) - } + // Clear drag start state + setDragStartPosition(null) + setPotentialParentId(null) + multiNodeDragStartRef.current.clear() + return } + // Single node drag - original logic + const finalPosition = getClampedPositionForNode(node.id, node.position, blocks, allNodes) + updateBlockPosition(node.id, finalPosition) // Record single move entry on drag end to avoid micro-moves @@ -2577,6 +2564,7 @@ const WorkflowContent = React.memo(() => { setDragStartPosition, addNotification, activeWorkflowId, + collaborativeBatchUpdatePositions, ] ) @@ -2596,47 +2584,41 @@ const WorkflowContent = React.memo(() => { requestAnimationFrame(() => setIsSelectionDragActive(false)) if (nodes.length === 0) return - const positionUpdates = nodes.map((node) => { - const currentBlock = blocks[node.id] - const currentParentId = currentBlock?.data?.parentId - let finalPosition = node.position - - if (currentParentId) { - const parentNode = getNodes().find((n) => n.id === currentParentId) - if (parentNode) { - const containerDimensions = { - width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - } - const blockDimensions = { - width: BLOCK_DIMENSIONS.FIXED_WIDTH, - height: Math.max( - currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT, - BLOCK_DIMENSIONS.MIN_HEIGHT - ), - } - finalPosition = clampPositionToContainer( - node.position, - containerDimensions, - blockDimensions - ) - } - } - - return { id: node.id, position: finalPosition } + const allNodes = getNodes() + const positionUpdates = computeClampedPositionUpdates(nodes, blocks, allNodes) + collaborativeBatchUpdatePositions(positionUpdates, { + previousPositions: multiNodeDragStartRef.current, }) - - collaborativeBatchUpdatePositions(positionUpdates) + multiNodeDragStartRef.current.clear() }, [blocks, getNodes, collaborativeBatchUpdatePositions] ) const onPaneClick = useCallback(() => { - setSelectedEdgeInfo(null) + setSelectedEdges(new Map()) usePanelEditorStore.getState().clearCurrentBlock() }, []) - /** Handles edge selection with container context tracking. */ + /** + * Handles node click to select the node in ReactFlow. + * This ensures clicking anywhere on a block (not just the drag handle) + * selects it for delete/backspace and multi-select operations. + */ + 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, + })) + ) + }, + [setNodes] + ) + + /** Handles edge selection with container context tracking and Shift-click multi-selection. */ const onEdgeClick = useCallback( (event: React.MouseEvent, edge: any) => { event.stopPropagation() // Prevent bubbling @@ -2652,11 +2634,21 @@ const WorkflowContent = React.memo(() => { // Create a unique identifier that combines edge ID and parent context const contextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}` - setSelectedEdgeInfo({ - id: edge.id, - parentLoopId, - contextId, - }) + if (event.shiftKey) { + // Shift-click: toggle edge in selection + setSelectedEdges((prev) => { + const next = new Map(prev) + if (next.has(contextId)) { + next.delete(contextId) + } else { + next.set(contextId, edge.id) + } + return next + }) + } else { + // Normal click: replace selection with this edge + setSelectedEdges(new Map([[contextId, edge.id]])) + } }, [getNodes] ) @@ -2665,7 +2657,16 @@ const WorkflowContent = React.memo(() => { const handleEdgeDelete = useCallback( (edgeId: string) => { removeEdge(edgeId) - setSelectedEdgeInfo((current) => (current?.id === edgeId ? null : current)) + // Remove this edge from selection (find by edge ID value) + setSelectedEdges((prev) => { + const next = new Map(prev) + for (const [contextId, id] of next) { + if (id === edgeId) { + next.delete(contextId) + } + } + return next + }) }, [removeEdge] ) @@ -2685,7 +2686,7 @@ const WorkflowContent = React.memo(() => { ...edge, data: { ...edge.data, - isSelected: selectedEdgeInfo?.contextId === edgeContextId, + isSelected: selectedEdges.has(edgeContextId), isInsideLoop: Boolean(parentLoopId), parentLoopId, sourceHandle: edge.sourceHandle, @@ -2693,7 +2694,7 @@ const WorkflowContent = React.memo(() => { }, } }) - }, [edgesForDisplay, displayNodes, selectedEdgeInfo?.contextId, handleEdgeDelete]) + }, [edgesForDisplay, displayNodes, selectedEdges, handleEdgeDelete]) /** Handles Delete/Backspace to remove selected edges or blocks. */ useEffect(() => { @@ -2703,20 +2704,16 @@ const WorkflowContent = React.memo(() => { } // Ignore when typing/navigating inside editable inputs or editors - const activeElement = document.activeElement - const isEditableElement = - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement?.hasAttribute('contenteditable') - - if (isEditableElement) { + if (isInEditableElement()) { return } // Handle edge deletion first (edges take priority if selected) - if (selectedEdgeInfo) { - removeEdge(selectedEdgeInfo.id) - setSelectedEdgeInfo(null) + if (selectedEdges.size > 0) { + // Get all selected edge IDs and batch delete them + const edgeIds = Array.from(selectedEdges.values()) + collaborativeBatchRemoveEdges(edgeIds) + setSelectedEdges(new Map()) return } @@ -2738,8 +2735,8 @@ const WorkflowContent = React.memo(() => { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [ - selectedEdgeInfo, - removeEdge, + selectedEdges, + collaborativeBatchRemoveEdges, getNodes, collaborativeBatchRemoveBlocks, effectivePermissions.canEdit, @@ -2796,6 +2793,7 @@ const WorkflowContent = React.memo(() => { connectionLineType={ConnectionLineType.SmoothStep} onPaneClick={onPaneClick} onEdgeClick={onEdgeClick} + onNodeClick={handleNodeClick} onPaneContextMenu={handlePaneContextMenu} onNodeContextMenu={handleNodeContextMenu} onSelectionContextMenu={handleSelectionContextMenu} @@ -2803,10 +2801,11 @@ const WorkflowContent = React.memo(() => { onPointerLeave={handleCanvasPointerLeave} elementsSelectable={true} selectionOnDrag={isShiftPressed || isSelectionDragActive} + selectionMode={SelectionMode.Partial} panOnDrag={isShiftPressed || isSelectionDragActive ? false : [0, 1]} onSelectionStart={onSelectionStart} onSelectionEnd={onSelectionEnd} - multiSelectionKeyCode={['Meta', 'Control']} + multiSelectionKeyCode={['Meta', 'Control', 'Shift']} nodesConnectable={effectivePermissions.canEdit} nodesDraggable={effectivePermissions.canEdit} draggable={false} diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx index 76ef355ee3..1e6f7bf424 100644 --- a/apps/sim/components/emails/components/email-footer.tsx +++ b/apps/sim/components/emails/components/email-footer.tsx @@ -112,7 +112,7 @@ export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }: {brand.name} - {isHosted && <>, 80 Langton St, San Francisco, CA 94133, USA} + {isHosted && <>, 80 Langton St, San Francisco, CA 94103, USA}   diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 4df0e00f40..9825ccea06 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -20,8 +20,6 @@ import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/wo const logger = createLogger('CollaborativeWorkflow') -const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath'] - export function useCollaborativeWorkflow() { const undoRedo = useUndoRedo() const isUndoRedoInProgress = useRef(false) @@ -33,7 +31,7 @@ export function useCollaborativeWorkflow() { const { blockId, before, after } = e.detail || {} if (!blockId || !before || !after) return if (isUndoRedoInProgress.current) return - undoRedo.recordMove(blockId, before, after) + undoRedo.recordBatchMoveBlocks([{ blockId, before, after }]) } const parentUpdateHandler = (e: any) => { @@ -290,6 +288,34 @@ export function useCollaborativeWorkflow() { break } } + } else if (target === 'edges') { + switch (operation) { + case 'batch-remove-edges': { + const { ids } = payload + if (Array.isArray(ids)) { + ids.forEach((id: string) => { + workflowStore.removeEdge(id) + }) + + const updatedBlocks = useWorkflowStore.getState().blocks + const updatedEdges = useWorkflowStore.getState().edges + const graph = { + blocksById: updatedBlocks, + edgesById: Object.fromEntries(updatedEdges.map((e) => [e.id, e])), + } + + const undoRedoStore = useUndoRedoStore.getState() + const stackKeys = Object.keys(undoRedoStore.stacks) + stackKeys.forEach((key) => { + const [wfId, uId] = key.split(':') + if (wfId === activeWorkflowId) { + undoRedoStore.pruneInvalidEntries(wfId, uId, graph) + } + }) + } + break + } + } } else if (target === 'subflow') { switch (operation) { case 'update': @@ -722,7 +748,12 @@ export function useCollaborativeWorkflow() { ) const collaborativeBatchUpdatePositions = useCallback( - (updates: Array<{ id: string; position: Position }>) => { + ( + updates: Array<{ id: string; position: Position }>, + options?: { + previousPositions?: Map + } + ) => { if (!isInActiveRoom()) { logger.debug('Skipping batch position update - not in active workflow') return @@ -746,8 +777,31 @@ export function useCollaborativeWorkflow() { updates.forEach(({ id, position }) => { workflowStore.updateBlockPosition(id, position) }) + + if (options?.previousPositions && options.previousPositions.size > 0) { + const moves = updates + .filter((u) => options.previousPositions!.has(u.id)) + .map((u) => { + const prev = options.previousPositions!.get(u.id)! + const block = workflowStore.blocks[u.id] + return { + blockId: u.id, + before: prev, + after: { + x: u.position.x, + y: u.position.y, + parentId: block?.data?.parentId, + }, + } + }) + .filter((m) => m.before.x !== m.after.x || m.before.y !== m.after.y) + + if (moves.length > 0) { + undoRedo.recordBatchMoveBlocks(moves) + } + } }, - [addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore] + [addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo] ) const collaborativeUpdateBlockName = useCallback( @@ -822,13 +876,43 @@ export function useCollaborativeWorkflow() { [executeQueuedOperation, workflowStore, addToQueue, activeWorkflowId, session?.user?.id] ) - const collaborativeToggleBlockEnabled = useCallback( - (id: string) => { - executeQueuedOperation('toggle-enabled', 'block', { id }, () => + const collaborativeBatchToggleBlockEnabled = useCallback( + (ids: string[]) => { + if (ids.length === 0) return + + const previousStates: Record = {} + const validIds: string[] = [] + + for (const id of ids) { + const block = workflowStore.blocks[id] + if (block) { + previousStates[id] = block.enabled + validIds.push(id) + } + } + + if (validIds.length === 0) return + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: 'batch-toggle-enabled', + target: 'blocks', + payload: { blockIds: validIds, previousStates }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + for (const id of validIds) { workflowStore.toggleBlockEnabled(id) - ) + } + + undoRedo.recordBatchToggleEnabled(validIds, previousStates) }, - [executeQueuedOperation, workflowStore] + [addToQueue, activeWorkflowId, session?.user?.id, workflowStore, undoRedo] ) const collaborativeUpdateParentId = useCallback( @@ -888,27 +972,48 @@ export function useCollaborativeWorkflow() { [executeQueuedOperation, workflowStore] ) - const collaborativeToggleBlockHandles = useCallback( - (id: string) => { - const currentBlock = workflowStore.blocks[id] - if (!currentBlock) return + const collaborativeBatchToggleBlockHandles = useCallback( + (ids: string[]) => { + if (ids.length === 0) return - const newHorizontalHandles = !currentBlock.horizontalHandles + const previousStates: Record = {} + const validIds: string[] = [] - executeQueuedOperation( - 'toggle-handles', - 'block', - { id, horizontalHandles: newHorizontalHandles }, - () => workflowStore.toggleBlockHandles(id) - ) + for (const id of ids) { + const block = workflowStore.blocks[id] + if (block) { + previousStates[id] = block.horizontalHandles ?? false + validIds.push(id) + } + } + + if (validIds.length === 0) return + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: 'batch-toggle-handles', + target: 'blocks', + payload: { blockIds: validIds, previousStates }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + for (const id of validIds) { + workflowStore.toggleBlockHandles(id) + } + + undoRedo.recordBatchToggleHandles(validIds, previousStates) }, - [executeQueuedOperation, workflowStore] + [addToQueue, activeWorkflowId, session?.user?.id, workflowStore, undoRedo] ) const collaborativeAddEdge = useCallback( (edge: Edge) => { executeQueuedOperation('add', 'edge', edge, () => workflowStore.addEdge(edge)) - // Only record edge addition if it's not part of a parent update operation if (!skipEdgeRecording.current) { undoRedo.recordAddEdge(edge.id) } @@ -920,13 +1025,11 @@ export function useCollaborativeWorkflow() { (edgeId: string) => { const edge = workflowStore.edges.find((e) => e.id === edgeId) - // Skip if edge doesn't exist (already removed during cascade deletion) if (!edge) { logger.debug('Edge already removed, skipping operation', { edgeId }) return } - // Check if the edge's source and target blocks still exist const sourceExists = workflowStore.blocks[edge.source] const targetExists = workflowStore.blocks[edge.target] @@ -939,9 +1042,8 @@ export function useCollaborativeWorkflow() { return } - // Only record edge removal if it's not part of a parent update operation if (!skipEdgeRecording.current) { - undoRedo.recordRemoveEdge(edgeId, edge) + undoRedo.recordBatchRemoveEdges([edge]) } executeQueuedOperation('remove', 'edge', { id: edgeId }, () => @@ -951,11 +1053,64 @@ export function useCollaborativeWorkflow() { [executeQueuedOperation, workflowStore, undoRedo] ) + const collaborativeBatchRemoveEdges = useCallback( + (edgeIds: string[], options?: { skipUndoRedo?: boolean }) => { + if (!isInActiveRoom()) { + logger.debug('Skipping batch remove edges - not in active workflow') + return false + } + + if (edgeIds.length === 0) return false + + const edgeSnapshots: Edge[] = [] + const validEdgeIds: string[] = [] + + for (const edgeId of edgeIds) { + const edge = workflowStore.edges.find((e) => e.id === edgeId) + if (edge) { + const sourceExists = workflowStore.blocks[edge.source] + const targetExists = workflowStore.blocks[edge.target] + if (sourceExists && targetExists) { + edgeSnapshots.push(edge) + validEdgeIds.push(edgeId) + } + } + } + + if (validEdgeIds.length === 0) { + logger.debug('No valid edges to remove') + return false + } + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: 'batch-remove-edges', + target: 'edges', + payload: { ids: validEdgeIds }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + validEdgeIds.forEach((id) => workflowStore.removeEdge(id)) + + if (!options?.skipUndoRedo && edgeSnapshots.length > 0) { + undoRedo.recordBatchRemoveEdges(edgeSnapshots) + } + + logger.info('Batch removed edges', { count: validEdgeIds.length }) + return true + }, + [isInActiveRoom, workflowStore, addToQueue, activeWorkflowId, session, undoRedo] + ) + const collaborativeSetSubblockValue = useCallback( (blockId: string, subblockId: string, value: any, options?: { _visited?: Set }) => { if (isApplyingRemoteChange.current) return - // Skip socket operations when viewing baseline diff if (isBaselineDiffView) { logger.debug('Skipping collaborative subblock update while viewing baseline diff') return @@ -971,13 +1126,10 @@ export function useCollaborativeWorkflow() { return } - // Generate operation ID for queue tracking const operationId = crypto.randomUUID() - // Get fresh activeWorkflowId from store to avoid stale closure const currentActiveWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - // Add to queue for retry mechanism addToQueue({ id: operationId, operation: { @@ -989,10 +1141,8 @@ export function useCollaborativeWorkflow() { userId: session?.user?.id || 'unknown', }) - // Apply locally first (immediate UI feedback) subBlockStore.setValue(blockId, subblockId, value) - // Declarative clearing: clear sub-blocks that depend on this subblockId try { const visited = options?._visited || new Set() if (visited.has(subblockId)) return @@ -1004,9 +1154,7 @@ export function useCollaborativeWorkflow() { (sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId) ) for (const dep of dependents) { - // Skip clearing if the dependent is the same field if (!dep?.id || dep.id === subblockId) continue - // Cascade using the same collaborative path so it emits and further cascades collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited }) } } @@ -1512,15 +1660,16 @@ export function useCollaborativeWorkflow() { // Collaborative operations collaborativeBatchUpdatePositions, collaborativeUpdateBlockName, - collaborativeToggleBlockEnabled, + collaborativeBatchToggleBlockEnabled, collaborativeUpdateParentId, collaborativeToggleBlockAdvancedMode, collaborativeToggleBlockTriggerMode, - collaborativeToggleBlockHandles, + collaborativeBatchToggleBlockHandles, collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, collaborativeAddEdge, collaborativeRemoveEdge, + collaborativeBatchRemoveEdges, collaborativeSetSubblockValue, collaborativeSetTagSelection, diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 33457cf390..aa40c9c70f 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -6,11 +6,13 @@ import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-o import { useOperationQueue } from '@/stores/operation-queue/store' import { type BatchAddBlocksOperation, + type BatchMoveBlocksOperation, type BatchRemoveBlocksOperation, + type BatchRemoveEdgesOperation, + type BatchToggleEnabledOperation, + type BatchToggleHandlesOperation, createOperationEntry, - type MoveBlockOperation, type Operation, - type RemoveEdgeOperation, runWithUndoRedoRecordingSuspended, type UpdateParentOperation, useUndoRedoStore, @@ -128,6 +130,8 @@ export function useUndoRedo() { (edgeId: string) => { if (!activeWorkflowId) return + const edgeSnapshot = workflowStore.edges.find((e) => e.id === edgeId) + const operation: Operation = { id: crypto.randomUUID(), type: 'add-edge', @@ -137,15 +141,15 @@ export function useUndoRedo() { data: { edgeId }, } - const inverse: RemoveEdgeOperation = { + // Inverse is batch-remove-edges with a single edge + const inverse: BatchRemoveEdgesOperation = { id: crypto.randomUUID(), - type: 'remove-edge', + type: 'batch-remove-edges', timestamp: Date.now(), workflowId: activeWorkflowId, userId, data: { - edgeId, - edgeSnapshot: workflowStore.edges.find((e) => e.id === edgeId) || null, + edgeSnapshots: edgeSnapshot ? [edgeSnapshot] : [], }, } @@ -157,77 +161,83 @@ export function useUndoRedo() { [activeWorkflowId, userId, workflowStore, undoRedoStore] ) - const recordRemoveEdge = useCallback( - (edgeId: string, edgeSnapshot: Edge) => { - if (!activeWorkflowId) return + const recordBatchRemoveEdges = useCallback( + (edgeSnapshots: Edge[]) => { + if (!activeWorkflowId || edgeSnapshots.length === 0) return - const operation: RemoveEdgeOperation = { + const operation: BatchRemoveEdgesOperation = { id: crypto.randomUUID(), - type: 'remove-edge', + type: 'batch-remove-edges', timestamp: Date.now(), workflowId: activeWorkflowId, userId, data: { - edgeId, - edgeSnapshot, + edgeSnapshots, }, } - const inverse: Operation = { + // Inverse is batch-add-edges (using the snapshots to restore) + const inverse: BatchRemoveEdgesOperation = { id: crypto.randomUUID(), - type: 'add-edge', + type: 'batch-remove-edges', timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { edgeId }, + data: { + edgeSnapshots, + }, } const entry = createOperationEntry(operation, inverse) undoRedoStore.push(activeWorkflowId, userId, entry) - logger.debug('Recorded remove edge', { edgeId, workflowId: activeWorkflowId }) + logger.debug('Recorded batch remove edges', { + edgeCount: edgeSnapshots.length, + workflowId: activeWorkflowId, + }) }, [activeWorkflowId, userId, undoRedoStore] ) - const recordMove = useCallback( + const recordBatchMoveBlocks = useCallback( ( - blockId: string, - before: { x: number; y: number; parentId?: string }, - after: { x: number; y: number; parentId?: string } + moves: Array<{ + blockId: string + before: { x: number; y: number; parentId?: string } + after: { x: number; y: number; parentId?: string } + }> ) => { - if (!activeWorkflowId) return + if (!activeWorkflowId || moves.length === 0) return - const operation: MoveBlockOperation = { + const operation: BatchMoveBlocksOperation = { id: crypto.randomUUID(), - type: 'move-block', + type: 'batch-move-blocks', timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { - blockId, - before, - after, - }, + data: { moves }, } - const inverse: MoveBlockOperation = { + // Inverse swaps before/after for each move + const inverse: BatchMoveBlocksOperation = { id: crypto.randomUUID(), - type: 'move-block', + type: 'batch-move-blocks', timestamp: Date.now(), workflowId: activeWorkflowId, userId, data: { - blockId, - before: after, - after: before, + moves: moves.map((m) => ({ + blockId: m.blockId, + before: m.after, + after: m.before, + })), }, } const entry = createOperationEntry(operation, inverse) undoRedoStore.push(activeWorkflowId, userId, entry) - logger.debug('Recorded move', { blockId, from: before, to: after }) + logger.debug('Recorded batch move', { blockCount: moves.length }) }, [activeWorkflowId, userId, undoRedoStore] ) @@ -288,6 +298,66 @@ export function useUndoRedo() { [activeWorkflowId, userId, undoRedoStore] ) + const recordBatchToggleEnabled = useCallback( + (blockIds: string[], previousStates: Record) => { + if (!activeWorkflowId || blockIds.length === 0) return + + const operation: BatchToggleEnabledOperation = { + id: crypto.randomUUID(), + type: 'batch-toggle-enabled', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { blockIds, previousStates }, + } + + const inverse: BatchToggleEnabledOperation = { + id: crypto.randomUUID(), + type: 'batch-toggle-enabled', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { blockIds, previousStates }, + } + + const entry = createOperationEntry(operation, inverse) + undoRedoStore.push(activeWorkflowId, userId, entry) + + logger.debug('Recorded batch toggle enabled', { blockIds, previousStates }) + }, + [activeWorkflowId, userId, undoRedoStore] + ) + + const recordBatchToggleHandles = useCallback( + (blockIds: string[], previousStates: Record) => { + if (!activeWorkflowId || blockIds.length === 0) return + + const operation: BatchToggleHandlesOperation = { + id: crypto.randomUUID(), + type: 'batch-toggle-handles', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { blockIds, previousStates }, + } + + const inverse: BatchToggleHandlesOperation = { + id: crypto.randomUUID(), + type: 'batch-toggle-handles', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { blockIds, previousStates }, + } + + const entry = createOperationEntry(operation, inverse) + undoRedoStore.push(activeWorkflowId, userId, entry) + + logger.debug('Recorded batch toggle handles', { blockIds, previousStates }) + }, + [activeWorkflowId, userId, undoRedoStore] + ) + const undo = useCallback(async () => { if (!activeWorkflowId) return @@ -422,93 +492,82 @@ export function useUndoRedo() { } break } - case 'remove-edge': { - const removeEdgeInverse = entry.inverse as RemoveEdgeOperation - const { edgeId } = removeEdgeInverse.data - if (workflowStore.edges.find((e) => e.id === edgeId)) { - addToQueue({ - id: opId, - operation: { - operation: 'remove', - target: 'edge', - payload: { - id: edgeId, - isUndo: true, - originalOpId: entry.id, + case 'batch-remove-edges': { + const batchRemoveInverse = entry.inverse as BatchRemoveEdgesOperation + const { edgeSnapshots } = batchRemoveInverse.data + + if (entry.operation.type === 'add-edge') { + // Undo add-edge: remove the edges that were added + const edgesToRemove = edgeSnapshots + .filter((e) => workflowStore.edges.find((edge) => edge.id === e.id)) + .map((e) => e.id) + + if (edgesToRemove.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: 'batch-remove-edges', + target: 'edges', + payload: { ids: edgesToRemove }, }, - }, - workflowId: activeWorkflowId, - userId, - }) - workflowStore.removeEdge(edgeId) + workflowId: activeWorkflowId, + userId, + }) + edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) + } + logger.debug('Undid add-edge', { edgeCount: edgesToRemove.length }) } else { - logger.debug('Undo remove-edge skipped; edge missing', { - edgeId, - }) - } - break - } - case 'add-edge': { - const originalOp = entry.operation as RemoveEdgeOperation - const { edgeSnapshot } = originalOp.data - // Skip if snapshot missing or already exists - if (!edgeSnapshot || workflowStore.edges.find((e) => e.id === edgeSnapshot.id)) { - logger.debug('Undo add-edge skipped', { - hasSnapshot: Boolean(edgeSnapshot), - }) - break + // Undo batch-remove-edges: add edges back + const edgesToAdd = edgeSnapshots.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + + if (edgesToAdd.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } + logger.debug('Undid batch-remove-edges', { edgeCount: edgesToAdd.length }) } - addToQueue({ - id: opId, - operation: { - operation: 'add', - target: 'edge', - payload: { ...edgeSnapshot, isUndo: true, originalOpId: entry.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - workflowStore.addEdge(edgeSnapshot) break } - case 'move-block': { - const moveOp = entry.inverse as MoveBlockOperation + case 'batch-move-blocks': { + const batchMoveOp = entry.inverse as BatchMoveBlocksOperation const currentBlocks = useWorkflowStore.getState().blocks - if (currentBlocks[moveOp.data.blockId]) { - // Apply the inverse's target as the undo result (inverse.after) + const positionUpdates: Array<{ id: string; position: { x: number; y: number } }> = [] + + for (const move of batchMoveOp.data.moves) { + if (currentBlocks[move.blockId]) { + positionUpdates.push({ + id: move.blockId, + position: { x: move.after.x, y: move.after.y }, + }) + workflowStore.updateBlockPosition(move.blockId, { + x: move.after.x, + y: move.after.y, + }) + } + } + + if (positionUpdates.length > 0) { addToQueue({ id: opId, operation: { - operation: 'update-position', - target: 'block', - payload: { - id: moveOp.data.blockId, - position: { x: moveOp.data.after.x, y: moveOp.data.after.y }, - parentId: moveOp.data.after.parentId, - commit: true, - isUndo: true, - originalOpId: entry.id, - }, + operation: 'batch-update-positions', + target: 'blocks', + payload: { updates: positionUpdates }, }, workflowId: activeWorkflowId, userId, }) - // Use the store from the hook context for React re-renders - workflowStore.updateBlockPosition(moveOp.data.blockId, { - x: moveOp.data.after.x, - y: moveOp.data.after.y, - }) - if (moveOp.data.after.parentId !== moveOp.data.before.parentId) { - workflowStore.updateParentId( - moveOp.data.blockId, - moveOp.data.after.parentId || '', - 'parent' - ) - } - } else { - logger.debug('Undo move-block skipped; block missing', { - blockId: moveOp.data.blockId, - }) } break } @@ -520,21 +579,22 @@ export function useUndoRedo() { if (workflowStore.blocks[blockId]) { // If we're moving back INTO a subflow, restore edges first if (newParentId && affectedEdges && affectedEdges.length > 0) { - affectedEdges.forEach((edge) => { - if (!workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.addEdge(edge) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'add', - target: 'edge', - payload: { ...edge, isUndo: true }, - }, - workflowId: activeWorkflowId, - userId, - }) - } - }) + const edgesToAdd = affectedEdges.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + if (edgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } } // Send position update to server @@ -602,8 +662,65 @@ export function useUndoRedo() { } break } + case 'batch-toggle-enabled': { + const toggleOp = entry.inverse as BatchToggleEnabledOperation + const { blockIds, previousStates } = toggleOp.data + + const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (validBlockIds.length === 0) { + logger.debug('Undo batch-toggle-enabled skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: 'batch-toggle-enabled', + target: 'blocks', + payload: { blockIds: validBlockIds, previousStates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + validBlockIds.forEach((blockId) => { + const targetState = previousStates[blockId] + if (workflowStore.blocks[blockId].enabled !== targetState) { + workflowStore.toggleBlockEnabled(blockId) + } + }) + break + } + case 'batch-toggle-handles': { + const toggleOp = entry.inverse as BatchToggleHandlesOperation + const { blockIds, previousStates } = toggleOp.data + + const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (validBlockIds.length === 0) { + logger.debug('Undo batch-toggle-handles skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: 'batch-toggle-handles', + target: 'blocks', + payload: { blockIds: validBlockIds, previousStates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + validBlockIds.forEach((blockId) => { + const targetState = previousStates[blockId] + if (workflowStore.blocks[blockId].horizontalHandles !== targetState) { + workflowStore.toggleBlockHandles(blockId) + } + }) + break + } case 'apply-diff': { - // Undo apply-diff means clearing the diff and restoring baseline const applyDiffInverse = entry.inverse as any const { baselineSnapshot } = applyDiffInverse.data @@ -667,7 +784,6 @@ export function useUndoRedo() { const acceptDiffInverse = entry.inverse as any const acceptDiffOp = entry.operation as any const { beforeAccept, diffAnalysis } = acceptDiffInverse.data - const { baselineSnapshot } = acceptDiffOp.data const { useWorkflowDiffStore } = await import('@/stores/workflow-diff/store') const diffStore = useWorkflowDiffStore.getState() @@ -725,7 +841,6 @@ export function useUndoRedo() { case 'reject-diff': { // Undo reject-diff means restoring diff view with markers const rejectDiffInverse = entry.inverse as any - const rejectDiffOp = entry.operation as any const { beforeReject, diffAnalysis, baselineSnapshot } = rejectDiffInverse.data const { useWorkflowDiffStore } = await import('@/stores/workflow-diff/store') const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') @@ -886,84 +1001,83 @@ export function useUndoRedo() { break } case 'add-edge': { - // Use snapshot from inverse - const inv = entry.inverse as RemoveEdgeOperation - const snap = inv.data.edgeSnapshot - if (!snap || workflowStore.edges.find((e) => e.id === snap.id)) { - logger.debug('Redo add-edge skipped', { hasSnapshot: Boolean(snap) }) - break + const inv = entry.inverse as BatchRemoveEdgesOperation + const edgeSnapshots = inv.data.edgeSnapshots + + const edgesToAdd = edgeSnapshots.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + + if (edgesToAdd.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) } - addToQueue({ - id: opId, - operation: { - operation: 'add', - target: 'edge', - payload: { ...snap, isRedo: true, originalOpId: entry.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - workflowStore.addEdge(snap) break } - case 'remove-edge': { - const { edgeId } = entry.operation.data - if (workflowStore.edges.find((e) => e.id === edgeId)) { + case 'batch-remove-edges': { + // Redo batch-remove-edges: remove all edges again + const batchRemoveOp = entry.operation as BatchRemoveEdgesOperation + const { edgeSnapshots } = batchRemoveOp.data + + const edgesToRemove = edgeSnapshots + .filter((e) => workflowStore.edges.find((edge) => edge.id === e.id)) + .map((e) => e.id) + + if (edgesToRemove.length > 0) { addToQueue({ id: opId, operation: { - operation: 'remove', - target: 'edge', - payload: { id: edgeId, isRedo: true, originalOpId: entry.id }, + operation: 'batch-remove-edges', + target: 'edges', + payload: { ids: edgesToRemove }, }, workflowId: activeWorkflowId, userId, }) - workflowStore.removeEdge(edgeId) - } else { - logger.debug('Redo remove-edge skipped; edge missing', { - edgeId, - }) + edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) } + + logger.debug('Redid batch-remove-edges', { edgeCount: edgesToRemove.length }) break } - case 'move-block': { - const moveOp = entry.operation as MoveBlockOperation + case 'batch-move-blocks': { + const batchMoveOp = entry.operation as BatchMoveBlocksOperation const currentBlocks = useWorkflowStore.getState().blocks - if (currentBlocks[moveOp.data.blockId]) { + const positionUpdates: Array<{ id: string; position: { x: number; y: number } }> = [] + + for (const move of batchMoveOp.data.moves) { + if (currentBlocks[move.blockId]) { + positionUpdates.push({ + id: move.blockId, + position: { x: move.after.x, y: move.after.y }, + }) + workflowStore.updateBlockPosition(move.blockId, { + x: move.after.x, + y: move.after.y, + }) + } + } + + if (positionUpdates.length > 0) { addToQueue({ id: opId, operation: { - operation: 'update-position', - target: 'block', - payload: { - id: moveOp.data.blockId, - position: { x: moveOp.data.after.x, y: moveOp.data.after.y }, - parentId: moveOp.data.after.parentId, - commit: true, - isRedo: true, - originalOpId: entry.id, - }, + operation: 'batch-update-positions', + target: 'blocks', + payload: { updates: positionUpdates }, }, workflowId: activeWorkflowId, userId, }) - // Use the store from the hook context for React re-renders - workflowStore.updateBlockPosition(moveOp.data.blockId, { - x: moveOp.data.after.x, - y: moveOp.data.after.y, - }) - if (moveOp.data.after.parentId !== moveOp.data.before.parentId) { - workflowStore.updateParentId( - moveOp.data.blockId, - moveOp.data.after.parentId || '', - 'parent' - ) - } - } else { - logger.debug('Redo move-block skipped; block missing', { - blockId: moveOp.data.blockId, - }) } break } @@ -1036,27 +1150,86 @@ export function useUndoRedo() { // If we're adding TO a subflow, restore edges after if (newParentId && affectedEdges && affectedEdges.length > 0) { - affectedEdges.forEach((edge) => { - if (!workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.addEdge(edge) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'add', - target: 'edge', - payload: { ...edge, isRedo: true }, - }, - workflowId: activeWorkflowId, - userId, - }) - } - }) + const edgesToAdd = affectedEdges.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + if (edgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } } } else { logger.debug('Redo update-parent skipped; block missing', { blockId }) } break } + case 'batch-toggle-enabled': { + const toggleOp = entry.operation as BatchToggleEnabledOperation + const { blockIds, previousStates } = toggleOp.data + + const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (validBlockIds.length === 0) { + logger.debug('Redo batch-toggle-enabled skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: 'batch-toggle-enabled', + target: 'blocks', + payload: { blockIds: validBlockIds, previousStates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + validBlockIds.forEach((blockId) => { + const targetState = !previousStates[blockId] + if (workflowStore.blocks[blockId].enabled !== targetState) { + workflowStore.toggleBlockEnabled(blockId) + } + }) + break + } + case 'batch-toggle-handles': { + const toggleOp = entry.operation as BatchToggleHandlesOperation + const { blockIds, previousStates } = toggleOp.data + + const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (validBlockIds.length === 0) { + logger.debug('Redo batch-toggle-handles skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: 'batch-toggle-handles', + target: 'blocks', + payload: { blockIds: validBlockIds, previousStates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + validBlockIds.forEach((blockId) => { + const targetState = !previousStates[blockId] + if (workflowStore.blocks[blockId].horizontalHandles !== targetState) { + workflowStore.toggleBlockHandles(blockId) + } + }) + break + } case 'apply-diff': { // Redo apply-diff means re-applying the proposed state with diff markers const applyDiffOp = entry.operation as any @@ -1372,9 +1545,11 @@ export function useUndoRedo() { recordBatchAddBlocks, recordBatchRemoveBlocks, recordAddEdge, - recordRemoveEdge, - recordMove, + recordBatchRemoveEdges, + recordBatchMoveBlocks, recordUpdateParent, + recordBatchToggleEnabled, + recordBatchToggleHandles, recordApplyDiff, recordAcceptDiff, recordRejectDiff, diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index 014cf08a63..865e51c6b6 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -179,6 +179,9 @@ export async function persistWorkflowOperation(workflowId: string, operation: an case 'edge': await handleEdgeOperationTx(tx, workflowId, op, payload) break + case 'edges': + await handleEdgesOperationTx(tx, workflowId, op, payload) + break case 'subflow': await handleSubflowOperationTx(tx, workflowId, op, payload) break @@ -690,6 +693,68 @@ async function handleBlocksOperationTx( break } + case 'batch-toggle-enabled': { + const { blockIds } = payload + if (!Array.isArray(blockIds) || blockIds.length === 0) { + return + } + + logger.info( + `Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}` + ) + + for (const blockId of blockIds) { + const block = await tx + .select({ enabled: workflowBlocks.enabled }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (block.length > 0) { + await tx + .update(workflowBlocks) + .set({ + enabled: !block[0].enabled, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + } + } + + logger.debug(`Batch toggled enabled state for ${blockIds.length} blocks`) + break + } + + case 'batch-toggle-handles': { + const { blockIds } = payload + if (!Array.isArray(blockIds) || blockIds.length === 0) { + return + } + + logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`) + + for (const blockId of blockIds) { + const block = await tx + .select({ horizontalHandles: workflowBlocks.horizontalHandles }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (block.length > 0) { + await tx + .update(workflowBlocks) + .set({ + horizontalHandles: !block[0].horizontalHandles, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + } + } + + logger.debug(`Batch toggled handles for ${blockIds.length} blocks`) + break + } + default: throw new Error(`Unsupported blocks operation: ${operation}`) } @@ -740,6 +805,60 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str } } +async function handleEdgesOperationTx( + tx: any, + workflowId: string, + operation: string, + payload: any +) { + switch (operation) { + case 'batch-remove-edges': { + const { ids } = payload + if (!Array.isArray(ids) || ids.length === 0) { + logger.debug('No edge IDs provided for batch remove') + return + } + + logger.info(`Batch removing ${ids.length} edges from workflow ${workflowId}`) + + await tx + .delete(workflowEdges) + .where(and(eq(workflowEdges.workflowId, workflowId), inArray(workflowEdges.id, ids))) + + logger.debug(`Batch removed ${ids.length} edges from workflow ${workflowId}`) + break + } + + case 'batch-add-edges': { + const { edges } = payload + if (!Array.isArray(edges) || edges.length === 0) { + logger.debug('No edges provided for batch add') + return + } + + logger.info(`Batch adding ${edges.length} edges to workflow ${workflowId}`) + + const edgeValues = edges.map((edge: Record) => ({ + id: edge.id as string, + workflowId, + sourceBlockId: edge.source as string, + targetBlockId: edge.target as string, + sourceHandle: (edge.sourceHandle as string | null) || null, + targetHandle: (edge.targetHandle as string | null) || null, + })) + + await tx.insert(workflowEdges).values(edgeValues) + + logger.debug(`Batch added ${edges.length} edges to workflow ${workflowId}`) + break + } + + default: + logger.warn(`Unknown edges operation: ${operation}`) + throw new Error(`Unsupported edges operation: ${operation}`) + } +} + async function handleSubflowOperationTx( tx: any, workflowId: string, diff --git a/apps/sim/socket/handlers/operations.ts b/apps/sim/socket/handlers/operations.ts index 7fd64995c9..eaeeab3062 100644 --- a/apps/sim/socket/handlers/operations.ts +++ b/apps/sim/socket/handlers/operations.ts @@ -317,6 +317,122 @@ export function setupOperationsHandlers( return } + if (target === 'edges' && operation === 'batch-remove-edges') { + 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 === 'blocks' && operation === 'batch-toggle-enabled') { + 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 === 'blocks' && operation === 'batch-toggle-handles') { + 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 === 'edges' && operation === 'batch-add-edges') { + 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 + } + // For non-position operations, persist first then broadcast await persistWorkflowOperation(workflowId, { operation, diff --git a/apps/sim/socket/middleware/permissions.ts b/apps/sim/socket/middleware/permissions.ts index 02aadfdde8..3a43010b99 100644 --- a/apps/sim/socket/middleware/permissions.ts +++ b/apps/sim/socket/middleware/permissions.ts @@ -16,6 +16,10 @@ const ROLE_PERMISSIONS: Record = { 'batch-update-positions', 'batch-add-blocks', 'batch-remove-blocks', + 'batch-add-edges', + 'batch-remove-edges', + 'batch-toggle-enabled', + 'batch-toggle-handles', 'update-name', 'toggle-enabled', 'update-parent', @@ -33,6 +37,10 @@ const ROLE_PERMISSIONS: Record = { 'batch-update-positions', 'batch-add-blocks', 'batch-remove-blocks', + 'batch-add-edges', + 'batch-remove-edges', + 'batch-toggle-enabled', + 'batch-toggle-handles', 'update-name', 'toggle-enabled', 'update-parent', diff --git a/apps/sim/socket/validation/schemas.ts b/apps/sim/socket/validation/schemas.ts index 71be529774..e1bd5f520f 100644 --- a/apps/sim/socket/validation/schemas.ts +++ b/apps/sim/socket/validation/schemas.ts @@ -5,7 +5,6 @@ const PositionSchema = z.object({ y: z.number(), }) -// Schema for auto-connect edge data const AutoConnectEdgeSchema = z.object({ id: z.string(), source: z.string(), @@ -148,12 +147,66 @@ export const BatchRemoveBlocksSchema = z.object({ operationId: z.string().optional(), }) +export const BatchRemoveEdgesSchema = z.object({ + operation: z.literal('batch-remove-edges'), + target: z.literal('edges'), + payload: z.object({ + ids: z.array(z.string()), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchAddEdgesSchema = z.object({ + operation: z.literal('batch-add-edges'), + target: z.literal('edges'), + payload: z.object({ + edges: z.array( + z.object({ + id: z.string(), + source: z.string(), + target: z.string(), + sourceHandle: z.string().nullable().optional(), + targetHandle: z.string().nullable().optional(), + }) + ), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchToggleEnabledSchema = z.object({ + operation: z.literal('batch-toggle-enabled'), + target: z.literal('blocks'), + payload: z.object({ + blockIds: z.array(z.string()), + previousStates: z.record(z.boolean()), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchToggleHandlesSchema = z.object({ + operation: z.literal('batch-toggle-handles'), + target: z.literal('blocks'), + payload: z.object({ + blockIds: z.array(z.string()), + previousStates: z.record(z.boolean()), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + export const WorkflowOperationSchema = z.union([ BlockOperationSchema, BatchPositionUpdateSchema, BatchAddBlocksSchema, BatchRemoveBlocksSchema, + BatchToggleEnabledSchema, + BatchToggleHandlesSchema, EdgeOperationSchema, + BatchAddEdgesSchema, + BatchRemoveEdgesSchema, SubflowOperationSchema, VariableOperationSchema, WorkflowStateOperationSchema, diff --git a/apps/sim/stores/undo-redo/store.test.ts b/apps/sim/stores/undo-redo/store.test.ts index 99f6f3827a..b583a01585 100644 --- a/apps/sim/stores/undo-redo/store.test.ts +++ b/apps/sim/stores/undo-redo/store.test.ts @@ -751,7 +751,7 @@ describe('useUndoRedoStore', () => { expect(getStackSizes(workflowId, userId).undoSize).toBe(3) const moveEntry = undo(workflowId, userId) - expect(moveEntry?.operation.type).toBe('move-block') + expect(moveEntry?.operation.type).toBe('batch-move-blocks') const parentEntry = undo(workflowId, userId) expect(parentEntry?.operation.type).toBe('update-parent') diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts index 07c67a0f5b..0d01f66d82 100644 --- a/apps/sim/stores/undo-redo/store.ts +++ b/apps/sim/stores/undo-redo/store.ts @@ -4,11 +4,11 @@ import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' import type { BatchAddBlocksOperation, + BatchMoveBlocksOperation, BatchRemoveBlocksOperation, - MoveBlockOperation, + BatchRemoveEdgesOperation, Operation, OperationEntry, - RemoveEdgeOperation, UndoRedoState, } from '@/stores/undo-redo/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -92,17 +92,17 @@ function isOperationApplicable( const op = operation as BatchAddBlocksOperation return op.data.blockSnapshots.every((block) => !graph.blocksById[block.id]) } - case 'move-block': { - const op = operation as MoveBlockOperation - return Boolean(graph.blocksById[op.data.blockId]) + case 'batch-move-blocks': { + const op = operation as BatchMoveBlocksOperation + return op.data.moves.every((move) => Boolean(graph.blocksById[move.blockId])) } case 'update-parent': { const blockId = operation.data.blockId return Boolean(graph.blocksById[blockId]) } - case 'remove-edge': { - const op = operation as RemoveEdgeOperation - return Boolean(graph.edgesById[op.data.edgeId]) + case 'batch-remove-edges': { + const op = operation as BatchRemoveEdgesOperation + return op.data.edgeSnapshots.every((edge) => Boolean(graph.edgesById[edge.id])) } case 'add-edge': { const edgeId = operation.data.edgeId @@ -198,62 +198,82 @@ export const useUndoRedoStore = create()( } } - // Coalesce consecutive move-block operations for the same block - if (entry.operation.type === 'move-block') { - const incoming = entry.operation as MoveBlockOperation + // Coalesce consecutive batch-move-blocks operations for overlapping blocks + if (entry.operation.type === 'batch-move-blocks') { + const incoming = entry.operation as BatchMoveBlocksOperation const last = stack.undo[stack.undo.length - 1] - // Skip no-op moves - const b1 = incoming.data.before - const a1 = incoming.data.after - const sameParent = (b1.parentId ?? null) === (a1.parentId ?? null) - if (b1.x === a1.x && b1.y === a1.y && sameParent) { - logger.debug('Skipped no-op move push') + // Skip no-op moves (all moves have same before/after) + const allNoOp = incoming.data.moves.every((move) => { + const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null) + return move.before.x === move.after.x && move.before.y === move.after.y && sameParent + }) + if (allNoOp) { + logger.debug('Skipped no-op batch move push') return } - if (last && last.operation.type === 'move-block' && last.inverse.type === 'move-block') { - const prev = last.operation as MoveBlockOperation - if (prev.data.blockId === incoming.data.blockId) { - // Merge: keep earliest before, latest after - const mergedBefore = prev.data.before - const mergedAfter = incoming.data.after + if ( + last && + last.operation.type === 'batch-move-blocks' && + last.inverse.type === 'batch-move-blocks' + ) { + const prev = last.operation as BatchMoveBlocksOperation + const prevBlockIds = new Set(prev.data.moves.map((m) => m.blockId)) + const incomingBlockIds = new Set(incoming.data.moves.map((m) => m.blockId)) + + // Check if same set of blocks + const sameBlocks = + prevBlockIds.size === incomingBlockIds.size && + [...prevBlockIds].every((id) => incomingBlockIds.has(id)) + + if (sameBlocks) { + // Merge: keep earliest before, latest after for each block + const mergedMoves = incoming.data.moves.map((incomingMove) => { + const prevMove = prev.data.moves.find((m) => m.blockId === incomingMove.blockId)! + return { + blockId: incomingMove.blockId, + before: prevMove.before, + after: incomingMove.after, + } + }) - const sameAfter = - mergedBefore.x === mergedAfter.x && - mergedBefore.y === mergedAfter.y && - (mergedBefore.parentId ?? null) === (mergedAfter.parentId ?? null) + // Check if all moves result in same position (net no-op) + const allSameAfter = mergedMoves.every((move) => { + const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null) + return ( + move.before.x === move.after.x && move.before.y === move.after.y && sameParent + ) + }) - const newUndoCoalesced: OperationEntry[] = sameAfter + const newUndoCoalesced: OperationEntry[] = allSameAfter ? stack.undo.slice(0, -1) : (() => { - const op = entry.operation as MoveBlockOperation - const inv = entry.inverse as MoveBlockOperation + const op = entry.operation as BatchMoveBlocksOperation + const inv = entry.inverse as BatchMoveBlocksOperation const newEntry: OperationEntry = { id: entry.id, createdAt: entry.createdAt, operation: { id: op.id, - type: 'move-block', + type: 'batch-move-blocks', timestamp: op.timestamp, workflowId, userId, - data: { - blockId: incoming.data.blockId, - before: mergedBefore, - after: mergedAfter, - }, + data: { moves: mergedMoves }, }, inverse: { id: inv.id, - type: 'move-block', + type: 'batch-move-blocks', timestamp: inv.timestamp, workflowId, userId, data: { - blockId: incoming.data.blockId, - before: mergedAfter, - after: mergedBefore, + moves: mergedMoves.map((m) => ({ + blockId: m.blockId, + before: m.after, + after: m.before, + })), }, }, } @@ -268,10 +288,10 @@ export const useUndoRedoStore = create()( set({ stacks: currentStacks }) - logger.debug('Coalesced consecutive move operations', { + logger.debug('Coalesced consecutive batch move operations', { workflowId, userId, - blockId: incoming.data.blockId, + blockCount: mergedMoves.length, undoSize: newUndoCoalesced.length, }) return diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index d69688e838..910a3f6e88 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -5,12 +5,14 @@ export type OperationType = | 'batch-add-blocks' | 'batch-remove-blocks' | 'add-edge' - | 'remove-edge' + | 'batch-remove-edges' | 'add-subflow' | 'remove-subflow' - | 'move-block' + | 'batch-move-blocks' | 'move-subflow' | 'update-parent' + | 'batch-toggle-enabled' + | 'batch-toggle-handles' | 'apply-diff' | 'accept-diff' | 'reject-diff' @@ -48,11 +50,10 @@ export interface AddEdgeOperation extends BaseOperation { } } -export interface RemoveEdgeOperation extends BaseOperation { - type: 'remove-edge' +export interface BatchRemoveEdgesOperation extends BaseOperation { + type: 'batch-remove-edges' data: { - edgeId: string - edgeSnapshot: Edge | null + edgeSnapshots: Edge[] } } @@ -71,20 +72,14 @@ export interface RemoveSubflowOperation extends BaseOperation { } } -export interface MoveBlockOperation extends BaseOperation { - type: 'move-block' +export interface BatchMoveBlocksOperation extends BaseOperation { + type: 'batch-move-blocks' data: { - blockId: string - before: { - x: number - y: number - parentId?: string - } - after: { - x: number - y: number - parentId?: string - } + moves: Array<{ + blockId: string + before: { x: number; y: number; parentId?: string } + after: { x: number; y: number; parentId?: string } + }> } } @@ -115,6 +110,22 @@ export interface UpdateParentOperation extends BaseOperation { } } +export interface BatchToggleEnabledOperation extends BaseOperation { + type: 'batch-toggle-enabled' + data: { + blockIds: string[] + previousStates: Record + } +} + +export interface BatchToggleHandlesOperation extends BaseOperation { + type: 'batch-toggle-handles' + data: { + blockIds: string[] + previousStates: Record + } +} + export interface ApplyDiffOperation extends BaseOperation { type: 'apply-diff' data: { @@ -148,12 +159,14 @@ export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation | AddEdgeOperation - | RemoveEdgeOperation + | BatchRemoveEdgesOperation | AddSubflowOperation | RemoveSubflowOperation - | MoveBlockOperation + | BatchMoveBlocksOperation | MoveSubflowOperation | UpdateParentOperation + | BatchToggleEnabledOperation + | BatchToggleHandlesOperation | ApplyDiffOperation | AcceptDiffOperation | RejectDiffOperation diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index e00209c203..c36ed933d9 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -1,6 +1,8 @@ import type { BatchAddBlocksOperation, + BatchMoveBlocksOperation, BatchRemoveBlocksOperation, + BatchRemoveEdgesOperation, Operation, OperationEntry, } from '@/stores/undo-redo/types' @@ -43,23 +45,27 @@ export function createInverseOperation(operation: Operation): Operation { } case 'add-edge': + // Note: add-edge only stores edgeId. The full edge snapshot is stored + // in the inverse operation when recording. This function can't create + // a complete inverse without the snapshot. return { ...operation, - type: 'remove-edge', + type: 'batch-remove-edges', data: { - edgeId: operation.data.edgeId, - edgeSnapshot: null, + edgeSnapshots: [], }, - } + } as BatchRemoveEdgesOperation - case 'remove-edge': + case 'batch-remove-edges': { + const op = operation as BatchRemoveEdgesOperation return { ...operation, - type: 'add-edge', + type: 'batch-remove-edges', data: { - edgeId: operation.data.edgeId, + edgeSnapshots: op.data.edgeSnapshots, }, - } + } as BatchRemoveEdgesOperation + } case 'add-subflow': return { @@ -80,15 +86,20 @@ export function createInverseOperation(operation: Operation): Operation { }, } - case 'move-block': + case 'batch-move-blocks': { + const op = operation as BatchMoveBlocksOperation return { ...operation, + type: 'batch-move-blocks', data: { - blockId: operation.data.blockId, - before: operation.data.after, - after: operation.data.before, + moves: op.data.moves.map((m) => ({ + blockId: m.blockId, + before: m.after, + after: m.before, + })), }, - } + } as BatchMoveBlocksOperation + } case 'move-subflow': return { @@ -145,6 +156,24 @@ export function createInverseOperation(operation: Operation): Operation { }, } + case 'batch-toggle-enabled': + return { + ...operation, + data: { + blockIds: operation.data.blockIds, + previousStates: operation.data.previousStates, + }, + } + + case 'batch-toggle-handles': + return { + ...operation, + data: { + blockIds: operation.data.blockIds, + previousStates: operation.data.previousStates, + }, + } + default: { const exhaustiveCheck: never = operation throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) @@ -189,12 +218,14 @@ export function operationToCollaborativePayload(operation: Operation): { payload: { id: operation.data.edgeId }, } - case 'remove-edge': + case 'batch-remove-edges': { + const op = operation as BatchRemoveEdgesOperation return { - operation: 'remove', - target: 'edge', - payload: { id: operation.data.edgeId }, + operation: 'batch-remove-edges', + target: 'edges', + payload: { ids: op.data.edgeSnapshots.map((e) => e.id) }, } + } case 'add-subflow': return { @@ -210,17 +241,21 @@ export function operationToCollaborativePayload(operation: Operation): { payload: { id: operation.data.subflowId }, } - case 'move-block': + case 'batch-move-blocks': { + const op = operation as BatchMoveBlocksOperation return { - operation: 'update-position', - target: 'block', + operation: 'batch-update-positions', + target: 'blocks', payload: { - id: operation.data.blockId, - x: operation.data.after.x, - y: operation.data.after.y, - parentId: operation.data.after.parentId, + moves: op.data.moves.map((m) => ({ + id: m.blockId, + x: m.after.x, + y: m.after.y, + parentId: m.after.parentId, + })), }, } + } case 'move-subflow': return { @@ -272,6 +307,26 @@ export function operationToCollaborativePayload(operation: Operation): { }, } + case 'batch-toggle-enabled': + return { + operation: 'batch-toggle-enabled', + target: 'blocks', + payload: { + blockIds: operation.data.blockIds, + previousStates: operation.data.previousStates, + }, + } + + case 'batch-toggle-handles': + return { + operation: 'batch-toggle-handles', + target: 'blocks', + payload: { + blockIds: operation.data.blockIds, + previousStates: operation.data.previousStates, + }, + } + default: { const exhaustiveCheck: never = operation throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) diff --git a/packages/testing/src/factories/index.ts b/packages/testing/src/factories/index.ts index 4a7f456b16..45854fd06e 100644 --- a/packages/testing/src/factories/index.ts +++ b/packages/testing/src/factories/index.ts @@ -123,6 +123,7 @@ export { type AddEdgeOperation, type BaseOperation, type BatchAddBlocksOperation, + type BatchMoveBlocksOperation, type BatchRemoveBlocksOperation, createAddBlockEntry, createAddEdgeEntry, @@ -130,7 +131,6 @@ export { createRemoveBlockEntry, createRemoveEdgeEntry, createUpdateParentEntry, - type MoveBlockOperation, type Operation, type OperationEntry, type OperationType, diff --git a/packages/testing/src/factories/undo-redo.factory.ts b/packages/testing/src/factories/undo-redo.factory.ts index d03c8cefe6..e55ace2475 100644 --- a/packages/testing/src/factories/undo-redo.factory.ts +++ b/packages/testing/src/factories/undo-redo.factory.ts @@ -10,7 +10,7 @@ export type OperationType = | 'batch-remove-blocks' | 'add-edge' | 'remove-edge' - | 'move-block' + | 'batch-move-blocks' | 'update-parent' /** @@ -25,14 +25,16 @@ export interface BaseOperation { } /** - * Move block operation data. + * Batch move blocks operation data. */ -export interface MoveBlockOperation extends BaseOperation { - type: 'move-block' +export interface BatchMoveBlocksOperation extends BaseOperation { + type: 'batch-move-blocks' data: { - blockId: string - before: { x: number; y: number; parentId?: string } - after: { x: number; y: number; parentId?: string } + moves: Array<{ + blockId: string + before: { x: number; y: number; parentId?: string } + after: { x: number; y: number; parentId?: string } + }> } } @@ -95,7 +97,7 @@ export type Operation = | BatchRemoveBlocksOperation | AddEdgeOperation | RemoveEdgeOperation - | MoveBlockOperation + | BatchMoveBlocksOperation | UpdateParentOperation /** @@ -275,7 +277,7 @@ interface MoveBlockOptions extends OperationEntryOptions { } /** - * Creates a mock move-block operation entry. + * Creates a mock batch-move-blocks operation entry for a single block. */ export function createMoveBlockEntry(blockId: string, options: MoveBlockOptions = {}): any { const { @@ -293,19 +295,19 @@ export function createMoveBlockEntry(blockId: string, options: MoveBlockOptions createdAt, operation: { id: nanoid(8), - type: 'move-block', + type: 'batch-move-blocks', timestamp, workflowId, userId, - data: { blockId, before, after }, + data: { moves: [{ blockId, before, after }] }, }, inverse: { id: nanoid(8), - type: 'move-block', + type: 'batch-move-blocks', timestamp, workflowId, userId, - data: { blockId, before: after, after: before }, + data: { moves: [{ blockId, before: after, after: before }] }, }, } } From dc0040d47ebd54843806f03347578b65eeffbed5 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 8 Jan 2026 18:14:00 -0800 Subject: [PATCH 02/35] feat(i18n): update translations (#2732) Co-authored-by: icecrasher321 --- .../docs/content/docs/de/enterprise/index.mdx | 76 +++++++++++++++++++ .../docs/content/docs/es/enterprise/index.mdx | 76 +++++++++++++++++++ .../docs/content/docs/fr/enterprise/index.mdx | 76 +++++++++++++++++++ .../docs/content/docs/ja/enterprise/index.mdx | 75 ++++++++++++++++++ .../docs/content/docs/zh/enterprise/index.mdx | 75 ++++++++++++++++++ apps/docs/i18n.lock | 27 +++++++ 6 files changed, 405 insertions(+) create mode 100644 apps/docs/content/docs/de/enterprise/index.mdx create mode 100644 apps/docs/content/docs/es/enterprise/index.mdx create mode 100644 apps/docs/content/docs/fr/enterprise/index.mdx create mode 100644 apps/docs/content/docs/ja/enterprise/index.mdx create mode 100644 apps/docs/content/docs/zh/enterprise/index.mdx diff --git a/apps/docs/content/docs/de/enterprise/index.mdx b/apps/docs/content/docs/de/enterprise/index.mdx new file mode 100644 index 0000000000..109b196491 --- /dev/null +++ b/apps/docs/content/docs/de/enterprise/index.mdx @@ -0,0 +1,76 @@ +--- +title: Enterprise +description: Enterprise-Funktionen für Organisationen mit erweiterten + Sicherheits- und Compliance-Anforderungen +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +Sim Studio Enterprise bietet erweiterte Funktionen für Organisationen mit erhöhten Sicherheits-, Compliance- und Verwaltungsanforderungen. + +--- + +## Bring Your Own Key (BYOK) + +Verwenden Sie Ihre eigenen API-Schlüssel für KI-Modellanbieter anstelle der gehosteten Schlüssel von Sim Studio. + +### Unterstützte Anbieter + +| Anbieter | Verwendung | +|----------|-------| +| OpenAI | Knowledge Base-Embeddings, Agent-Block | +| Anthropic | Agent-Block | +| Google | Agent-Block | +| Mistral | Knowledge Base OCR | + +### Einrichtung + +1. Navigieren Sie zu **Einstellungen** → **BYOK** in Ihrem Workspace +2. Klicken Sie auf **Schlüssel hinzufügen** für Ihren Anbieter +3. Geben Sie Ihren API-Schlüssel ein und speichern Sie + + + BYOK-Schlüssel werden verschlüsselt gespeichert. Nur Organisationsadministratoren und -inhaber können Schlüssel verwalten. + + +Wenn konfiguriert, verwenden Workflows Ihren Schlüssel anstelle der gehosteten Schlüssel von Sim Studio. Bei Entfernung wechseln Workflows automatisch zu den gehosteten Schlüsseln zurück. + +--- + +## Single Sign-On (SSO) + +Enterprise-Authentifizierung mit SAML 2.0- und OIDC-Unterstützung für zentralisiertes Identitätsmanagement. + +### Unterstützte Anbieter + +- Okta +- Azure AD / Entra ID +- Google Workspace +- OneLogin +- Jeder SAML 2.0- oder OIDC-Anbieter + +### Einrichtung + +1. Navigieren Sie zu **Einstellungen** → **SSO** in Ihrem Workspace +2. Wählen Sie Ihren Identitätsanbieter +3. Konfigurieren Sie die Verbindung mithilfe der Metadaten Ihres IdP +4. Aktivieren Sie SSO für Ihre Organisation + + + Sobald SSO aktiviert ist, authentifizieren sich Teammitglieder über Ihren Identitätsanbieter anstelle von E-Mail/Passwort. + + +--- + +## Self-Hosted + +Für selbst gehostete Bereitstellungen können Enterprise-Funktionen über Umgebungsvariablen aktiviert werden: + +| Variable | Beschreibung | +|----------|-------------| +| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On mit SAML/OIDC | +| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling-Gruppen für E-Mail-Trigger | + + + BYOK ist nur im gehosteten Sim Studio verfügbar. Selbst gehostete Deployments konfigurieren AI-Provider-Schlüssel direkt über Umgebungsvariablen. + diff --git a/apps/docs/content/docs/es/enterprise/index.mdx b/apps/docs/content/docs/es/enterprise/index.mdx new file mode 100644 index 0000000000..48c3f59241 --- /dev/null +++ b/apps/docs/content/docs/es/enterprise/index.mdx @@ -0,0 +1,76 @@ +--- +title: Enterprise +description: Funciones enterprise para organizaciones con requisitos avanzados + de seguridad y cumplimiento +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +Sim Studio Enterprise proporciona funciones avanzadas para organizaciones con requisitos mejorados de seguridad, cumplimiento y gestión. + +--- + +## Bring Your Own Key (BYOK) + +Usa tus propias claves API para proveedores de modelos de IA en lugar de las claves alojadas de Sim Studio. + +### Proveedores compatibles + +| Proveedor | Uso | +|----------|-------| +| OpenAI | Embeddings de base de conocimiento, bloque Agent | +| Anthropic | Bloque Agent | +| Google | Bloque Agent | +| Mistral | OCR de base de conocimiento | + +### Configuración + +1. Navega a **Configuración** → **BYOK** en tu espacio de trabajo +2. Haz clic en **Añadir clave** para tu proveedor +3. Introduce tu clave API y guarda + + + Las claves BYOK están cifradas en reposo. Solo los administradores y propietarios de la organización pueden gestionar las claves. + + +Cuando está configurado, los flujos de trabajo usan tu clave en lugar de las claves alojadas de Sim Studio. Si se elimina, los flujos de trabajo vuelven automáticamente a las claves alojadas. + +--- + +## Single Sign-On (SSO) + +Autenticación enterprise con soporte SAML 2.0 y OIDC para gestión centralizada de identidades. + +### Proveedores compatibles + +- Okta +- Azure AD / Entra ID +- Google Workspace +- OneLogin +- Cualquier proveedor SAML 2.0 u OIDC + +### Configuración + +1. Navega a **Configuración** → **SSO** en tu espacio de trabajo +2. Elige tu proveedor de identidad +3. Configura la conexión usando los metadatos de tu IdP +4. Activa SSO para tu organización + + + Una vez que SSO está activado, los miembros del equipo se autentican a través de tu proveedor de identidad en lugar de correo electrónico/contraseña. + + +--- + +## Self-Hosted + +Para implementaciones self-hosted, las funciones enterprise se pueden activar mediante variables de entorno: + +| Variable | Descripción | +|----------|-------------| +| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Inicio de sesión único con SAML/OIDC | +| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Grupos de sondeo para activadores de correo electrónico | + + + BYOK solo está disponible en Sim Studio alojado. Las implementaciones autoalojadas configuran las claves de proveedor de IA directamente a través de variables de entorno. + diff --git a/apps/docs/content/docs/fr/enterprise/index.mdx b/apps/docs/content/docs/fr/enterprise/index.mdx new file mode 100644 index 0000000000..46efa6b6ac --- /dev/null +++ b/apps/docs/content/docs/fr/enterprise/index.mdx @@ -0,0 +1,76 @@ +--- +title: Entreprise +description: Fonctionnalités entreprise pour les organisations ayant des + exigences avancées en matière de sécurité et de conformité +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +Sim Studio Entreprise fournit des fonctionnalités avancées pour les organisations ayant des exigences renforcées en matière de sécurité, de conformité et de gestion. + +--- + +## Apportez votre propre clé (BYOK) + +Utilisez vos propres clés API pour les fournisseurs de modèles IA au lieu des clés hébergées par Sim Studio. + +### Fournisseurs pris en charge + +| Fournisseur | Utilisation | +|----------|-------| +| OpenAI | Embeddings de base de connaissances, bloc Agent | +| Anthropic | Bloc Agent | +| Google | Bloc Agent | +| Mistral | OCR de base de connaissances | + +### Configuration + +1. Accédez à **Paramètres** → **BYOK** dans votre espace de travail +2. Cliquez sur **Ajouter une clé** pour votre fournisseur +3. Saisissez votre clé API et enregistrez + + + Les clés BYOK sont chiffrées au repos. Seuls les administrateurs et propriétaires de l'organisation peuvent gérer les clés. + + +Une fois configurés, les workflows utilisent votre clé au lieu des clés hébergées par Sim Studio. Si elle est supprimée, les workflows basculent automatiquement vers les clés hébergées. + +--- + +## Authentification unique (SSO) + +Authentification entreprise avec prise en charge de SAML 2.0 et OIDC pour une gestion centralisée des identités. + +### Fournisseurs pris en charge + +- Okta +- Azure AD / Entra ID +- Google Workspace +- OneLogin +- Tout fournisseur SAML 2.0 ou OIDC + +### Configuration + +1. Accédez à **Paramètres** → **SSO** dans votre espace de travail +2. Choisissez votre fournisseur d'identité +3. Configurez la connexion en utilisant les métadonnées de votre IdP +4. Activez le SSO pour votre organisation + + + Une fois le SSO activé, les membres de l'équipe s'authentifient via votre fournisseur d'identité au lieu d'utiliser un email/mot de passe. + + +--- + +## Auto-hébergé + +Pour les déploiements auto-hébergés, les fonctionnalités entreprise peuvent être activées via des variables d'environnement : + +| Variable | Description | +|----------|-------------| +| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Authentification unique avec SAML/OIDC | +| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Groupes de sondage pour les déclencheurs d'e-mail | + + + BYOK est uniquement disponible sur Sim Studio hébergé. Les déploiements auto-hébergés configurent les clés de fournisseur d'IA directement via les variables d'environnement. + diff --git a/apps/docs/content/docs/ja/enterprise/index.mdx b/apps/docs/content/docs/ja/enterprise/index.mdx new file mode 100644 index 0000000000..a08a5a51d5 --- /dev/null +++ b/apps/docs/content/docs/ja/enterprise/index.mdx @@ -0,0 +1,75 @@ +--- +title: エンタープライズ +description: 高度なセキュリティとコンプライアンス要件を持つ組織向けのエンタープライズ機能 +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +Sim Studio Enterpriseは、強化されたセキュリティ、コンプライアンス、管理要件を持つ組織向けの高度な機能を提供します。 + +--- + +## Bring Your Own Key (BYOK) + +Sim Studioのホストキーの代わりに、AIモデルプロバイダー用の独自のAPIキーを使用できます。 + +### 対応プロバイダー + +| プロバイダー | 用途 | +|----------|-------| +| OpenAI | ナレッジベースの埋め込み、エージェントブロック | +| Anthropic | エージェントブロック | +| Google | エージェントブロック | +| Mistral | ナレッジベースOCR | + +### セットアップ + +1. ワークスペースの**設定** → **BYOK**に移動します +2. プロバイダーの**キーを追加**をクリックします +3. APIキーを入力して保存します + + + BYOKキーは保存時に暗号化されます。組織の管理者とオーナーのみがキーを管理できます。 + + +設定すると、ワークフローはSim Studioのホストキーの代わりに独自のキーを使用します。削除すると、ワークフローは自動的にホストキーにフォールバックします。 + +--- + +## シングルサインオン (SSO) + +集中型IDマネジメントのためのSAML 2.0およびOIDCサポートを備えたエンタープライズ認証。 + +### 対応プロバイダー + +- Okta +- Azure AD / Entra ID +- Google Workspace +- OneLogin +- SAML 2.0またはOIDCに対応する任意のプロバイダー + +### セットアップ + +1. ワークスペースの**設定** → **SSO**に移動します +2. IDプロバイダーを選択します +3. IdPのメタデータを使用して接続を設定します +4. 組織のSSOを有効にします + + + SSOを有効にすると、チームメンバーはメール/パスワードの代わりにIDプロバイダーを通じて認証します。 + + +--- + +## セルフホスト + +セルフホストデプロイメントの場合、エンタープライズ機能は環境変数を介して有効にできます: + +| 変数 | 説明 | +|----------|-------------| +| `SSO_ENABLED`、`NEXT_PUBLIC_SSO_ENABLED` | SAML/OIDCによるシングルサインオン | +| `CREDENTIAL_SETS_ENABLED`、`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | メールトリガー用のポーリンググループ | + + + BYOKはホスト型Sim Studioでのみ利用可能です。セルフホスト型デプロイメントでは、環境変数を介してAIプロバイダーキーを直接設定します。 + diff --git a/apps/docs/content/docs/zh/enterprise/index.mdx b/apps/docs/content/docs/zh/enterprise/index.mdx new file mode 100644 index 0000000000..045a14ea4d --- /dev/null +++ b/apps/docs/content/docs/zh/enterprise/index.mdx @@ -0,0 +1,75 @@ +--- +title: 企业版 +description: 为具有高级安全性和合规性需求的组织提供企业级功能 +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +Sim Studio 企业版为需要更高安全性、合规性和管理能力的组织提供高级功能。 + +--- + +## 自带密钥(BYOK) + +使用您自己的 API 密钥对接 AI 模型服务商,而不是使用 Sim Studio 托管的密钥。 + +### 支持的服务商 + +| Provider | Usage | +|----------|-------| +| OpenAI | 知识库嵌入、Agent 模块 | +| Anthropic | Agent 模块 | +| Google | Agent 模块 | +| Mistral | 知识库 OCR | + +### 配置方法 + +1. 在您的工作区进入 **设置** → **BYOK** +2. 为您的服务商点击 **添加密钥** +3. 输入您的 API 密钥并保存 + + + BYOK 密钥静态加密存储。仅组织管理员和所有者可管理密钥。 + + +配置后,工作流将使用您的密钥而非 Sim Studio 托管密钥。如移除,工作流会自动切换回托管密钥。 + +--- + +## 单点登录(SSO) + +企业级身份认证,支持 SAML 2.0 和 OIDC,实现集中式身份管理。 + +### 支持的服务商 + +- Okta +- Azure AD / Entra ID +- Google Workspace +- OneLogin +- 任何 SAML 2.0 或 OIDC 服务商 + +### 配置方法 + +1. 在您的工作区进入 **设置** → **SSO** +2. 选择您的身份提供商 +3. 使用 IdP 元数据配置连接 +4. 为您的组织启用 SSO + + + 启用 SSO 后,团队成员将通过您的身份提供商进行身份验证,而不再使用邮箱/密码。 + + +--- + +## 自主部署 + +对于自主部署场景,可通过环境变量启用企业功能: + +| 变量 | 描述 | +|----------|-------------| +| `SSO_ENABLED`,`NEXT_PUBLIC_SSO_ENABLED` | 使用 SAML/OIDC 的单点登录 | +| `CREDENTIAL_SETS_ENABLED`,`NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | 用于邮件触发器的轮询组 | + + + BYOK 仅适用于托管版 Sim Studio。自托管部署需通过环境变量直接配置 AI 提供商密钥。 + diff --git a/apps/docs/i18n.lock b/apps/docs/i18n.lock index 4b66c6c2d7..99e4ad41d1 100644 --- a/apps/docs/i18n.lock +++ b/apps/docs/i18n.lock @@ -50308,3 +50308,30 @@ checksums: content/68: ba6b5020ed971cd7ffc7f0423650dfbf content/69: b3f310d5ef115bea5a8b75bf25d7ea9a content/70: 0362be478aa7ba4b6d1ebde0bd83e83a + f5bc5f89ed66818f4c485c554bf26eea: + meta/title: c70474271708e5b27392fde87462fa26 + meta/description: 7b47db7fbb818c180b99354b912a72b3 + content/0: 232be69c8f3053a40f695f9c9dcb3f2e + content/1: a4a62a6e782e18bd863546dfcf2aec1c + content/2: 51adf33450cab2ef392e93147386647c + content/3: ada515cf6e2e0f9d3f57f720f79699d3 + content/4: d5e8b9f64d855675588845dc4124c491 + content/5: 3acf1f0551f6097ca6159e66f5c8da1a + content/6: 6a6e277ded1a063ec2c2067abb519088 + content/7: 6debcd334c3310480cbe6feab87f37b5 + content/8: 0e3372052a2b3a1c43d853d6ed269d69 + content/9: 90063613714128f4e61e9588e2d2c735 + content/10: 182154179fe2a8b6b73fde0d04e0bf4c + content/11: 51adf33450cab2ef392e93147386647c + content/12: 73c3e8a5d36d6868fdb455fcb3d6074c + content/13: 30cd8f1d6197bce560a091ba19d0392a + content/14: 3acf1f0551f6097ca6159e66f5c8da1a + content/15: 997deef758698d207be9382c45301ad6 + content/16: 6debcd334c3310480cbe6feab87f37b5 + content/17: e26c8c2dffd70baef0253720c1511886 + content/18: a99eba53979531f1c974cf653c346909 + content/19: 51adf33450cab2ef392e93147386647c + content/20: ca3ec889fb218b8b130959ff04baa659 + content/21: 306617201cf63b42f09bb72c9722e048 + content/22: 4b48ba3f10b043f74b70edeb4ad87080 + content/23: c8531bd570711abc1963d8b5dcf9deef From e054cef0d6d2671ee31d3b4f965a3e4d4a9d4db9 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 18:18:19 -0800 Subject: [PATCH 03/35] don't allow flip handles for subflows --- .../[workflowId]/components/context-menu/block-context-menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx index 6ae1f22e09..547098f7bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx @@ -118,7 +118,7 @@ export function BlockContextMenu({ {getToggleEnabledLabel()} )} - {!allNoteBlocks && ( + {!allNoteBlocks && !isSubflow && ( { From 757343df84ed5a9f76062f63f876b84e173c1070 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 18:34:07 -0800 Subject: [PATCH 04/35] ack PR comments --- apps/sim/hooks/use-undo-redo.ts | 7 ++-- apps/sim/socket/database/operations.ts | 58 ++++++++++++-------------- apps/sim/stores/undo-redo/types.ts | 9 ++++ apps/sim/stores/undo-redo/utils.ts | 33 ++++++++++++++- 4 files changed, 69 insertions(+), 38 deletions(-) diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index aa40c9c70f..536941c720 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -6,6 +6,7 @@ import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-o import { useOperationQueue } from '@/stores/operation-queue/store' import { type BatchAddBlocksOperation, + type BatchAddEdgesOperation, type BatchMoveBlocksOperation, type BatchRemoveBlocksOperation, type BatchRemoveEdgesOperation, @@ -141,7 +142,6 @@ export function useUndoRedo() { data: { edgeId }, } - // Inverse is batch-remove-edges with a single edge const inverse: BatchRemoveEdgesOperation = { id: crypto.randomUUID(), type: 'batch-remove-edges', @@ -176,10 +176,9 @@ export function useUndoRedo() { }, } - // Inverse is batch-add-edges (using the snapshots to restore) - const inverse: BatchRemoveEdgesOperation = { + const inverse: BatchAddEdgesOperation = { id: crypto.randomUUID(), - type: 'batch-remove-edges', + type: 'batch-add-edges', timestamp: Date.now(), workflowId: activeWorkflowId, userId, diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index 865e51c6b6..cdd4ff1872 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -703,25 +703,22 @@ async function handleBlocksOperationTx( `Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}` ) - for (const blockId of blockIds) { - const block = await tx - .select({ enabled: workflowBlocks.enabled }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) + const blocks = await tx + .select({ id: workflowBlocks.id, enabled: workflowBlocks.enabled }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds))) - if (block.length > 0) { - await tx - .update(workflowBlocks) - .set({ - enabled: !block[0].enabled, - updatedAt: new Date(), - }) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - } + for (const block of blocks) { + await tx + .update(workflowBlocks) + .set({ + enabled: !block.enabled, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId))) } - logger.debug(`Batch toggled enabled state for ${blockIds.length} blocks`) + logger.debug(`Batch toggled enabled state for ${blocks.length} blocks`) break } @@ -733,25 +730,22 @@ async function handleBlocksOperationTx( logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`) - for (const blockId of blockIds) { - const block = await tx - .select({ horizontalHandles: workflowBlocks.horizontalHandles }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) + const blocks = await tx + .select({ id: workflowBlocks.id, horizontalHandles: workflowBlocks.horizontalHandles }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds))) - if (block.length > 0) { - await tx - .update(workflowBlocks) - .set({ - horizontalHandles: !block[0].horizontalHandles, - updatedAt: new Date(), - }) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - } + for (const block of blocks) { + await tx + .update(workflowBlocks) + .set({ + horizontalHandles: !block.horizontalHandles, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId))) } - logger.debug(`Batch toggled handles for ${blockIds.length} blocks`) + logger.debug(`Batch toggled handles for ${blocks.length} blocks`) break } diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index 910a3f6e88..7973f48587 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -5,6 +5,7 @@ export type OperationType = | 'batch-add-blocks' | 'batch-remove-blocks' | 'add-edge' + | 'batch-add-edges' | 'batch-remove-edges' | 'add-subflow' | 'remove-subflow' @@ -50,6 +51,13 @@ export interface AddEdgeOperation extends BaseOperation { } } +export interface BatchAddEdgesOperation extends BaseOperation { + type: 'batch-add-edges' + data: { + edgeSnapshots: Edge[] + } +} + export interface BatchRemoveEdgesOperation extends BaseOperation { type: 'batch-remove-edges' data: { @@ -159,6 +167,7 @@ export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation | AddEdgeOperation + | BatchAddEdgesOperation | BatchRemoveEdgesOperation | AddSubflowOperation | RemoveSubflowOperation diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index c36ed933d9..d2d5e40876 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -1,5 +1,6 @@ import type { BatchAddBlocksOperation, + BatchAddEdgesOperation, BatchMoveBlocksOperation, BatchRemoveBlocksOperation, BatchRemoveEdgesOperation, @@ -56,8 +57,8 @@ export function createInverseOperation(operation: Operation): Operation { }, } as BatchRemoveEdgesOperation - case 'batch-remove-edges': { - const op = operation as BatchRemoveEdgesOperation + case 'batch-add-edges': { + const op = operation as BatchAddEdgesOperation return { ...operation, type: 'batch-remove-edges', @@ -67,6 +68,17 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchRemoveEdgesOperation } + case 'batch-remove-edges': { + const op = operation as BatchRemoveEdgesOperation + return { + ...operation, + type: 'batch-add-edges', + data: { + edgeSnapshots: op.data.edgeSnapshots, + }, + } as BatchAddEdgesOperation + } + case 'add-subflow': return { ...operation, @@ -218,6 +230,23 @@ export function operationToCollaborativePayload(operation: Operation): { payload: { id: operation.data.edgeId }, } + case 'batch-add-edges': { + const op = operation as BatchAddEdgesOperation + return { + operation: 'batch-add-edges', + target: 'edges', + payload: { + edges: op.data.edgeSnapshots.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + sourceHandle: e.sourceHandle ?? null, + targetHandle: e.targetHandle ?? null, + })), + }, + } + } + case 'batch-remove-edges': { const op = operation as BatchRemoveEdgesOperation return { From e1036e33ffc1e0722c485952a9bc219692d625ee Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 18:56:27 -0800 Subject: [PATCH 05/35] more --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 13 +++--- apps/sim/hooks/use-undo-redo.ts | 28 ++++++------- apps/sim/stores/workflows/workflow/store.ts | 41 +++++++++++++++++++ apps/sim/stores/workflows/workflow/types.ts | 2 + 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 2462672ea3..bb24aa3af7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2396,11 +2396,14 @@ const WorkflowContent = React.memo(() => { const selectedNodes = allNodes.filter((n) => n.selected) multiNodeDragStartRef.current.clear() selectedNodes.forEach((n) => { - multiNodeDragStartRef.current.set(n.id, { - x: n.position.x, - y: n.position.y, - parentId: blocks[n.id]?.data?.parentId, - }) + const block = blocks[n.id] + if (block) { + multiNodeDragStartRef.current.set(n.id, { + x: n.position.x, + y: n.position.y, + parentId: block.data?.parentId, + }) + } }) }, [blocks, setDragStartPosition, getNodes] diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 536941c720..318b752a3c 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -682,11 +682,10 @@ export function useUndoRedo() { userId, }) + // Use setBlockEnabled to directly restore to previous state + // This is more robust than conditional toggle in collaborative scenarios validBlockIds.forEach((blockId) => { - const targetState = previousStates[blockId] - if (workflowStore.blocks[blockId].enabled !== targetState) { - workflowStore.toggleBlockEnabled(blockId) - } + workflowStore.setBlockEnabled(blockId, previousStates[blockId]) }) break } @@ -711,11 +710,10 @@ export function useUndoRedo() { userId, }) + // Use setBlockHandles to directly restore to previous state + // This is more robust than conditional toggle in collaborative scenarios validBlockIds.forEach((blockId) => { - const targetState = previousStates[blockId] - if (workflowStore.blocks[blockId].horizontalHandles !== targetState) { - workflowStore.toggleBlockHandles(blockId) - } + workflowStore.setBlockHandles(blockId, previousStates[blockId]) }) break } @@ -1192,11 +1190,10 @@ export function useUndoRedo() { userId, }) + // Use setBlockEnabled to directly set to toggled state + // Redo sets to !previousStates (the state after the original toggle) validBlockIds.forEach((blockId) => { - const targetState = !previousStates[blockId] - if (workflowStore.blocks[blockId].enabled !== targetState) { - workflowStore.toggleBlockEnabled(blockId) - } + workflowStore.setBlockEnabled(blockId, !previousStates[blockId]) }) break } @@ -1221,11 +1218,10 @@ export function useUndoRedo() { userId, }) + // Use setBlockHandles to directly set to toggled state + // Redo sets to !previousStates (the state after the original toggle) validBlockIds.forEach((blockId) => { - const targetState = !previousStates[blockId] - if (workflowStore.blocks[blockId].horizontalHandles !== targetState) { - workflowStore.toggleBlockHandles(blockId) - } + workflowStore.setBlockHandles(blockId, !previousStates[blockId]) }) break } diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 41d3051637..a217964b36 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -586,6 +586,27 @@ export const useWorkflowStore = create()( // Note: Socket.IO handles real-time sync automatically }, + setBlockEnabled: (id: string, enabled: boolean) => { + const block = get().blocks[id] + if (!block || block.enabled === enabled) return + + const newState = { + blocks: { + ...get().blocks, + [id]: { + ...block, + enabled, + }, + }, + edges: [...get().edges], + loops: { ...get().loops }, + parallels: { ...get().parallels }, + } + + set(newState) + get().updateLastSaved() + }, + duplicateBlock: (id: string) => { const block = get().blocks[id] if (!block) return @@ -668,6 +689,26 @@ export const useWorkflowStore = create()( // Note: Socket.IO handles real-time sync automatically }, + setBlockHandles: (id: string, horizontalHandles: boolean) => { + const block = get().blocks[id] + if (!block || block.horizontalHandles === horizontalHandles) return + + const newState = { + blocks: { + ...get().blocks, + [id]: { + ...block, + horizontalHandles, + }, + }, + edges: [...get().edges], + loops: { ...get().loops }, + } + + set(newState) + get().updateLastSaved() + }, + updateBlockName: (id: string, name: string) => { const oldBlock = get().blocks[id] if (!oldBlock) return { success: false, changedSubblocks: [] } diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index c836b8040c..023a223dec 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -195,8 +195,10 @@ export interface WorkflowActions { clear: () => Partial updateLastSaved: () => void toggleBlockEnabled: (id: string) => void + setBlockEnabled: (id: string, enabled: boolean) => void duplicateBlock: (id: string) => void toggleBlockHandles: (id: string) => void + setBlockHandles: (id: string, horizontalHandles: boolean) => void updateBlockName: ( id: string, name: string From a8fb76b4506b18f94c13cb8231ac7dab53ceab9e Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 19:02:30 -0800 Subject: [PATCH 06/35] fix missing handler --- apps/sim/hooks/use-undo-redo.ts | 107 ++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 39 deletions(-) diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 318b752a3c..bf6b2c236e 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -492,50 +492,53 @@ export function useUndoRedo() { break } case 'batch-remove-edges': { + // Undo add-edge: inverse is batch-remove-edges, so remove the edges const batchRemoveInverse = entry.inverse as BatchRemoveEdgesOperation const { edgeSnapshots } = batchRemoveInverse.data - if (entry.operation.type === 'add-edge') { - // Undo add-edge: remove the edges that were added - const edgesToRemove = edgeSnapshots - .filter((e) => workflowStore.edges.find((edge) => edge.id === e.id)) - .map((e) => e.id) - - if (edgesToRemove.length > 0) { - addToQueue({ - id: opId, - operation: { - operation: 'batch-remove-edges', - target: 'edges', - payload: { ids: edgesToRemove }, - }, - workflowId: activeWorkflowId, - userId, - }) - edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) - } - logger.debug('Undid add-edge', { edgeCount: edgesToRemove.length }) - } else { - // Undo batch-remove-edges: add edges back - const edgesToAdd = edgeSnapshots.filter( - (e) => !workflowStore.edges.find((edge) => edge.id === e.id) - ) + const edgesToRemove = edgeSnapshots + .filter((e) => workflowStore.edges.find((edge) => edge.id === e.id)) + .map((e) => e.id) - if (edgesToAdd.length > 0) { - addToQueue({ - id: opId, - operation: { - operation: 'batch-add-edges', - target: 'edges', - payload: { edges: edgesToAdd }, - }, - workflowId: activeWorkflowId, - userId, - }) - edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) - } - logger.debug('Undid batch-remove-edges', { edgeCount: edgesToAdd.length }) + if (edgesToRemove.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: 'batch-remove-edges', + target: 'edges', + payload: { ids: edgesToRemove }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) + } + logger.debug('Undid add-edge', { edgeCount: edgesToRemove.length }) + break + } + case 'batch-add-edges': { + // Undo batch-remove-edges: inverse is batch-add-edges, so add edges back + const batchAddInverse = entry.inverse as BatchAddEdgesOperation + const { edgeSnapshots } = batchAddInverse.data + + const edgesToAdd = edgeSnapshots.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + + if (edgesToAdd.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) } + logger.debug('Undid batch-remove-edges', { edgeCount: edgesToAdd.length }) break } case 'batch-move-blocks': { @@ -1046,6 +1049,32 @@ export function useUndoRedo() { logger.debug('Redid batch-remove-edges', { edgeCount: edgesToRemove.length }) break } + case 'batch-add-edges': { + // Redo batch-add-edges: add all edges again + const batchAddOp = entry.operation as BatchAddEdgesOperation + const { edgeSnapshots } = batchAddOp.data + + const edgesToAdd = edgeSnapshots.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + + if (edgesToAdd.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } + + logger.debug('Redid batch-add-edges', { edgeCount: edgesToAdd.length }) + break + } case 'batch-move-blocks': { const batchMoveOp = entry.operation as BatchMoveBlocksOperation const currentBlocks = useWorkflowStore.getState().blocks From 0568704c69d1ac1ff22975923b1aa422ac9ad344 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 19:30:12 -0800 Subject: [PATCH 07/35] remove dead subflow-specific ops --- apps/sim/hooks/use-undo-redo.ts | 3 +- apps/sim/stores/undo-redo/store.test.ts | 19 ++++--- apps/sim/stores/undo-redo/store.ts | 12 ++--- apps/sim/stores/undo-redo/types.ts | 36 ------------- apps/sim/stores/undo-redo/utils.ts | 54 ------------------- packages/testing/src/factories/index.ts | 5 +- .../src/factories/undo-redo.factory.ts | 49 +++++++++++------ 7 files changed, 54 insertions(+), 124 deletions(-) diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index bf6b2c236e..4660ffeee2 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -425,7 +425,8 @@ export function useUndoRedo() { break } case 'batch-add-blocks': { - const batchAddOp = entry.operation as BatchAddBlocksOperation + // Undoing a removal: inverse is batch-add-blocks, use entry.inverse for data + const batchAddOp = entry.inverse as BatchAddBlocksOperation const { blockSnapshots, edgeSnapshots, subBlockValues } = batchAddOp.data const blocksToAdd = blockSnapshots.filter((b) => !workflowStore.blocks[b.id]) diff --git a/apps/sim/stores/undo-redo/store.test.ts b/apps/sim/stores/undo-redo/store.test.ts index b583a01585..3f8a5a3c07 100644 --- a/apps/sim/stores/undo-redo/store.test.ts +++ b/apps/sim/stores/undo-redo/store.test.ts @@ -13,11 +13,11 @@ import { createAddBlockEntry, createAddEdgeEntry, + createBatchRemoveEdgesEntry, createBlock, createMockStorage, createMoveBlockEntry, createRemoveBlockEntry, - createRemoveEdgeEntry, createUpdateParentEntry, } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' @@ -603,16 +603,17 @@ describe('useUndoRedoStore', () => { expect(getStackSizes(workflowId, userId).undoSize).toBe(2) }) - it('should handle remove-edge operations', () => { + it('should handle batch-remove-edges operations', () => { const { push, undo, getStackSizes } = useUndoRedoStore.getState() - push(workflowId, userId, createRemoveEdgeEntry('edge-1', null, { workflowId, userId })) + const edgeSnapshot = { id: 'edge-1', source: 'block-1', target: 'block-2' } + push(workflowId, userId, createBatchRemoveEdgesEntry([edgeSnapshot], { workflowId, userId })) expect(getStackSizes(workflowId, userId).undoSize).toBe(1) const entry = undo(workflowId, userId) - expect(entry?.operation.type).toBe('remove-edge') - expect(entry?.inverse.type).toBe('add-edge') + expect(entry?.operation.type).toBe('batch-remove-edges') + expect(entry?.inverse.type).toBe('batch-add-edges') }) }) @@ -672,8 +673,10 @@ describe('useUndoRedoStore', () => { it('should remove entries for non-existent edges', () => { const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState() - push(workflowId, userId, createRemoveEdgeEntry('edge-1', null, { workflowId, userId })) - push(workflowId, userId, createRemoveEdgeEntry('edge-2', null, { workflowId, userId })) + const edge1 = { id: 'edge-1', source: 'a', target: 'b' } + const edge2 = { id: 'edge-2', source: 'c', target: 'd' } + push(workflowId, userId, createBatchRemoveEdgesEntry([edge1], { workflowId, userId })) + push(workflowId, userId, createBatchRemoveEdgesEntry([edge2], { workflowId, userId })) expect(getStackSizes(workflowId, userId).undoSize).toBe(2) @@ -686,6 +689,8 @@ describe('useUndoRedoStore', () => { pruneInvalidEntries(workflowId, userId, graph as any) + // edge-1 exists in graph, so we can't undo its removal (can't add it back) → pruned + // edge-2 doesn't exist, so we can undo its removal (can add it back) → kept expect(getStackSizes(workflowId, userId).undoSize).toBe(1) }) }) diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts index 0d01f66d82..3aabbd8558 100644 --- a/apps/sim/stores/undo-redo/store.ts +++ b/apps/sim/stores/undo-redo/store.ts @@ -4,6 +4,7 @@ import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' import type { BatchAddBlocksOperation, + BatchAddEdgesOperation, BatchMoveBlocksOperation, BatchRemoveBlocksOperation, BatchRemoveEdgesOperation, @@ -104,17 +105,14 @@ function isOperationApplicable( const op = operation as BatchRemoveEdgesOperation return op.data.edgeSnapshots.every((edge) => Boolean(graph.edgesById[edge.id])) } + case 'batch-add-edges': { + const op = operation as BatchAddEdgesOperation + return op.data.edgeSnapshots.every((edge) => !graph.edgesById[edge.id]) + } case 'add-edge': { const edgeId = operation.data.edgeId return !graph.edgesById[edgeId] } - case 'add-subflow': - case 'remove-subflow': { - const subflowId = operation.data.subflowId - return operation.type === 'remove-subflow' - ? Boolean(graph.blocksById[subflowId]) - : !graph.blocksById[subflowId] - } default: return true } diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index 7973f48587..e32b793447 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -7,10 +7,7 @@ export type OperationType = | 'add-edge' | 'batch-add-edges' | 'batch-remove-edges' - | 'add-subflow' - | 'remove-subflow' | 'batch-move-blocks' - | 'move-subflow' | 'update-parent' | 'batch-toggle-enabled' | 'batch-toggle-handles' @@ -65,21 +62,6 @@ export interface BatchRemoveEdgesOperation extends BaseOperation { } } -export interface AddSubflowOperation extends BaseOperation { - type: 'add-subflow' - data: { - subflowId: string - } -} - -export interface RemoveSubflowOperation extends BaseOperation { - type: 'remove-subflow' - data: { - subflowId: string - subflowSnapshot: BlockState | null - } -} - export interface BatchMoveBlocksOperation extends BaseOperation { type: 'batch-move-blocks' data: { @@ -91,21 +73,6 @@ export interface BatchMoveBlocksOperation extends BaseOperation { } } -export interface MoveSubflowOperation extends BaseOperation { - type: 'move-subflow' - data: { - subflowId: string - before: { - x: number - y: number - } - after: { - x: number - y: number - } - } -} - export interface UpdateParentOperation extends BaseOperation { type: 'update-parent' data: { @@ -169,10 +136,7 @@ export type Operation = | AddEdgeOperation | BatchAddEdgesOperation | BatchRemoveEdgesOperation - | AddSubflowOperation - | RemoveSubflowOperation | BatchMoveBlocksOperation - | MoveSubflowOperation | UpdateParentOperation | BatchToggleEnabledOperation | BatchToggleHandlesOperation diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index d2d5e40876..94c2c43fde 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -79,25 +79,6 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchAddEdgesOperation } - case 'add-subflow': - return { - ...operation, - type: 'remove-subflow', - data: { - subflowId: operation.data.subflowId, - subflowSnapshot: null, - }, - } - - case 'remove-subflow': - return { - ...operation, - type: 'add-subflow', - data: { - subflowId: operation.data.subflowId, - }, - } - case 'batch-move-blocks': { const op = operation as BatchMoveBlocksOperation return { @@ -113,16 +94,6 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchMoveBlocksOperation } - case 'move-subflow': - return { - ...operation, - data: { - subflowId: operation.data.subflowId, - before: operation.data.after, - after: operation.data.before, - }, - } - case 'update-parent': return { ...operation, @@ -256,20 +227,6 @@ export function operationToCollaborativePayload(operation: Operation): { } } - case 'add-subflow': - return { - operation: 'add', - target: 'subflow', - payload: { id: operation.data.subflowId }, - } - - case 'remove-subflow': - return { - operation: 'remove', - target: 'subflow', - payload: { id: operation.data.subflowId }, - } - case 'batch-move-blocks': { const op = operation as BatchMoveBlocksOperation return { @@ -286,17 +243,6 @@ export function operationToCollaborativePayload(operation: Operation): { } } - case 'move-subflow': - return { - operation: 'update-position', - target: 'subflow', - payload: { - id: operation.data.subflowId, - x: operation.data.after.x, - y: operation.data.after.y, - }, - } - case 'update-parent': return { operation: 'update-parent', diff --git a/packages/testing/src/factories/index.ts b/packages/testing/src/factories/index.ts index 45854fd06e..0eb9e6f5ca 100644 --- a/packages/testing/src/factories/index.ts +++ b/packages/testing/src/factories/index.ts @@ -123,18 +123,19 @@ export { type AddEdgeOperation, type BaseOperation, type BatchAddBlocksOperation, + type BatchAddEdgesOperation, type BatchMoveBlocksOperation, type BatchRemoveBlocksOperation, + type BatchRemoveEdgesOperation, createAddBlockEntry, createAddEdgeEntry, + createBatchRemoveEdgesEntry, createMoveBlockEntry, createRemoveBlockEntry, - createRemoveEdgeEntry, createUpdateParentEntry, type Operation, type OperationEntry, type OperationType, - type RemoveEdgeOperation, type UpdateParentOperation, } from './undo-redo.factory' // User/workspace factories diff --git a/packages/testing/src/factories/undo-redo.factory.ts b/packages/testing/src/factories/undo-redo.factory.ts index e55ace2475..ac2432d85b 100644 --- a/packages/testing/src/factories/undo-redo.factory.ts +++ b/packages/testing/src/factories/undo-redo.factory.ts @@ -9,7 +9,8 @@ export type OperationType = | 'batch-add-blocks' | 'batch-remove-blocks' | 'add-edge' - | 'remove-edge' + | 'batch-add-edges' + | 'batch-remove-edges' | 'batch-move-blocks' | 'update-parent' @@ -71,11 +72,19 @@ export interface AddEdgeOperation extends BaseOperation { } /** - * Remove edge operation data. + * Batch add edges operation data. */ -export interface RemoveEdgeOperation extends BaseOperation { - type: 'remove-edge' - data: { edgeId: string; edgeSnapshot: any } +export interface BatchAddEdgesOperation extends BaseOperation { + type: 'batch-add-edges' + data: { edgeSnapshots: any[] } +} + +/** + * Batch remove edges operation data. + */ +export interface BatchRemoveEdgesOperation extends BaseOperation { + type: 'batch-remove-edges' + data: { edgeSnapshots: any[] } } /** @@ -96,7 +105,8 @@ export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation | AddEdgeOperation - | RemoveEdgeOperation + | BatchAddEdgesOperation + | BatchRemoveEdgesOperation | BatchMoveBlocksOperation | UpdateParentOperation @@ -212,10 +222,16 @@ export function createRemoveBlockEntry( /** * Creates a mock add-edge operation entry. */ -export function createAddEdgeEntry(edgeId: string, options: OperationEntryOptions = {}): any { +export function createAddEdgeEntry( + edgeId: string, + edgeSnapshot: any = null, + options: OperationEntryOptions = {} +): any { const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options const timestamp = Date.now() + const snapshot = edgeSnapshot || { id: edgeId, source: 'block-1', target: 'block-2' } + return { id, createdAt, @@ -229,21 +245,20 @@ export function createAddEdgeEntry(edgeId: string, options: OperationEntryOption }, inverse: { id: nanoid(8), - type: 'remove-edge', + type: 'batch-remove-edges', timestamp, workflowId, userId, - data: { edgeId, edgeSnapshot: null }, + data: { edgeSnapshots: [snapshot] }, }, } } /** - * Creates a mock remove-edge operation entry. + * Creates a mock batch-remove-edges operation entry. */ -export function createRemoveEdgeEntry( - edgeId: string, - edgeSnapshot: any = null, +export function createBatchRemoveEdgesEntry( + edgeSnapshots: any[], options: OperationEntryOptions = {} ): any { const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options @@ -254,19 +269,19 @@ export function createRemoveEdgeEntry( createdAt, operation: { id: nanoid(8), - type: 'remove-edge', + type: 'batch-remove-edges', timestamp, workflowId, userId, - data: { edgeId, edgeSnapshot }, + data: { edgeSnapshots }, }, inverse: { id: nanoid(8), - type: 'add-edge', + type: 'batch-add-edges', timestamp, workflowId, userId, - data: { edgeId }, + data: { edgeSnapshots }, }, } } From 1029ba0e4df7b2e544cf6de8acf5e9f784c04496 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 19:33:04 -0800 Subject: [PATCH 08/35] remove unused code --- apps/sim/stores/undo-redo/utils.ts | 145 ----------------------------- 1 file changed, 145 deletions(-) diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index 94c2c43fde..aef24740a4 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -163,148 +163,3 @@ export function createInverseOperation(operation: Operation): Operation { } } } - -export function operationToCollaborativePayload(operation: Operation): { - operation: string - target: string - payload: Record -} { - switch (operation.type) { - case 'batch-add-blocks': { - const op = operation as BatchAddBlocksOperation - return { - operation: 'batch-add-blocks', - target: 'blocks', - payload: { - blocks: op.data.blockSnapshots, - edges: op.data.edgeSnapshots, - loops: {}, - parallels: {}, - subBlockValues: op.data.subBlockValues, - }, - } - } - - case 'batch-remove-blocks': { - const op = operation as BatchRemoveBlocksOperation - return { - operation: 'batch-remove-blocks', - target: 'blocks', - payload: { ids: op.data.blockSnapshots.map((b) => b.id) }, - } - } - - case 'add-edge': - return { - operation: 'add', - target: 'edge', - payload: { id: operation.data.edgeId }, - } - - case 'batch-add-edges': { - const op = operation as BatchAddEdgesOperation - return { - operation: 'batch-add-edges', - target: 'edges', - payload: { - edges: op.data.edgeSnapshots.map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - sourceHandle: e.sourceHandle ?? null, - targetHandle: e.targetHandle ?? null, - })), - }, - } - } - - case 'batch-remove-edges': { - const op = operation as BatchRemoveEdgesOperation - return { - operation: 'batch-remove-edges', - target: 'edges', - payload: { ids: op.data.edgeSnapshots.map((e) => e.id) }, - } - } - - case 'batch-move-blocks': { - const op = operation as BatchMoveBlocksOperation - return { - operation: 'batch-update-positions', - target: 'blocks', - payload: { - moves: op.data.moves.map((m) => ({ - id: m.blockId, - x: m.after.x, - y: m.after.y, - parentId: m.after.parentId, - })), - }, - } - } - - case 'update-parent': - return { - operation: 'update-parent', - target: 'block', - payload: { - id: operation.data.blockId, - parentId: operation.data.newParentId, - x: operation.data.newPosition.x, - y: operation.data.newPosition.y, - }, - } - - case 'apply-diff': - return { - operation: 'apply-diff', - target: 'workflow', - payload: { - diffAnalysis: operation.data.diffAnalysis, - }, - } - - case 'accept-diff': - return { - operation: 'accept-diff', - target: 'workflow', - payload: { - diffAnalysis: operation.data.diffAnalysis, - }, - } - - case 'reject-diff': - return { - operation: 'reject-diff', - target: 'workflow', - payload: { - diffAnalysis: operation.data.diffAnalysis, - }, - } - - case 'batch-toggle-enabled': - return { - operation: 'batch-toggle-enabled', - target: 'blocks', - payload: { - blockIds: operation.data.blockIds, - previousStates: operation.data.previousStates, - }, - } - - case 'batch-toggle-handles': - return { - operation: 'batch-toggle-handles', - target: 'blocks', - payload: { - blockIds: operation.data.blockIds, - previousStates: operation.data.previousStates, - }, - } - - default: { - const exhaustiveCheck: never = operation - throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) - } - } -} From 20d66b9ab1a3d48246d3cdc2cdebd4ffa1464562 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 20:53:34 -0800 Subject: [PATCH 09/35] fixed subflow ops --- .../workflow-edge/workflow-edge.tsx | 26 +- .../[workflowId]/hooks/use-node-utilities.ts | 10 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 237 +++++++++++++++- apps/sim/hooks/use-collaborative-workflow.ts | 66 +++++ apps/sim/hooks/use-undo-redo.ts | 256 +++++++++++++++--- apps/sim/socket/database/operations.ts | 71 +++++ apps/sim/socket/handlers/operations.ts | 29 ++ apps/sim/socket/middleware/permissions.ts | 2 + apps/sim/socket/validation/schemas.ts | 17 ++ apps/sim/stores/undo-redo/store.test.ts | 2 +- apps/sim/stores/undo-redo/store.ts | 9 +- apps/sim/stores/undo-redo/types.ts | 25 +- apps/sim/stores/undo-redo/utils.ts | 30 +- packages/testing/src/factories/index.ts | 5 +- .../src/factories/undo-redo.factory.ts | 104 ++++++- 15 files changed, 782 insertions(+), 107 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index 14e64108c8..39cfe25a13 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -40,10 +40,7 @@ const WorkflowEdgeComponent = ({ }) const isSelected = data?.isSelected ?? false - const isInsideLoop = data?.isInsideLoop ?? false - const parentLoopId = data?.parentLoopId - // Combined store subscription to reduce subscription overhead const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore( useShallow((state) => ({ diffAnalysis: state.diffAnalysis, @@ -57,7 +54,6 @@ const WorkflowEdgeComponent = ({ const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error' const edgeRunStatus = lastRunEdges.get(id) - // Memoize diff status calculation to avoid recomputing on every render const edgeDiffStatus = useMemo((): EdgeDiffStatus => { if (data?.isDeleted) return 'deleted' if (!diffAnalysis?.edge_diff || !isDiffReady) return null @@ -84,7 +80,6 @@ const WorkflowEdgeComponent = ({ targetHandle, ]) - // Memoize edge style to prevent object recreation const edgeStyle = useMemo(() => { let color = 'var(--workflow-edge)' if (edgeDiffStatus === 'deleted') color = 'var(--text-error)' @@ -104,30 +99,14 @@ const WorkflowEdgeComponent = ({ return ( <> - - {/* Animate dash offset for edge movement effect */} - + {isSelected && (
    ) { childNodes.forEach((node) => { const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id) - // Use block position from store if available (more up-to-date) - const block = blocks[node.id] - const position = block?.position || node.position - maxRight = Math.max(maxRight, position.x + nodeWidth) - maxBottom = Math.max(maxBottom, position.y + nodeHeight) + // Use ReactFlow's node.position which is already in the correct coordinate system + // (relative to parent for child nodes). The store's block.position may be stale + // or still in absolute coordinates during parent updates. + maxRight = Math.max(maxRight, node.position.x + nodeWidth) + maxBottom = Math.max(maxBottom, node.position.y + nodeHeight) }) const width = Math.max( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index bb24aa3af7..db0462a0ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -447,6 +447,7 @@ const WorkflowContent = React.memo(() => { collaborativeBatchRemoveEdges, collaborativeBatchUpdatePositions, collaborativeUpdateParentId: updateParentId, + collaborativeBatchUpdateParent, collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, collaborativeBatchToggleBlockEnabled, @@ -2425,6 +2426,43 @@ const WorkflowContent = React.memo(() => { previousPositions: multiNodeDragStartRef.current, }) + // Process parent updates for all selected nodes if dropping into a subflow + if (potentialParentId && potentialParentId !== dragStartParentId) { + // Filter out nodes that cannot be moved into subflows + const validNodes = selectedNodes.filter((n) => { + const block = blocks[n.id] + if (!block) return false + // Starter blocks cannot be in containers + if (n.data?.type === 'starter') return false + // Trigger blocks cannot be in containers + if (TriggerUtils.isTriggerBlock(block)) return false + // Subflow nodes (loop/parallel) cannot be nested + if (n.type === 'subflowNode') return false + return true + }) + + if (validNodes.length > 0) { + // Build updates for all valid nodes + const updates = validNodes.map((n) => { + const edgesToRemove = edgesForDisplay.filter( + (e) => e.source === n.id || e.target === n.id + ) + return { + blockId: n.id, + newParentId: potentialParentId, + affectedEdges: edgesToRemove, + } + }) + + collaborativeBatchUpdateParent(updates) + + logger.info('Batch moved nodes into subflow', { + targetParentId: potentialParentId, + nodeCount: validNodes.length, + }) + } + } + // Clear drag start state setDragStartPosition(null) setPotentialParentId(null) @@ -2568,6 +2606,7 @@ const WorkflowContent = React.memo(() => { addNotification, activeWorkflowId, collaborativeBatchUpdatePositions, + collaborativeBatchUpdateParent, ] ) @@ -2582,9 +2621,157 @@ const WorkflowContent = React.memo(() => { requestAnimationFrame(() => setIsSelectionDragActive(false)) }, []) + /** Captures initial positions when selection drag starts (for marquee-selected nodes). */ + const onSelectionDragStart = useCallback( + (_event: React.MouseEvent, nodes: Node[]) => { + // Capture the parent ID of the first node as reference (they should all be in the same context) + if (nodes.length > 0) { + const firstNodeParentId = blocks[nodes[0].id]?.data?.parentId || null + setDragStartParentId(firstNodeParentId) + } + + // Capture all selected nodes' positions for undo/redo + multiNodeDragStartRef.current.clear() + nodes.forEach((n) => { + const block = blocks[n.id] + if (block) { + multiNodeDragStartRef.current.set(n.id, { + x: n.position.x, + y: n.position.y, + parentId: block.data?.parentId, + }) + } + }) + }, + [blocks] + ) + + /** Handles selection drag to detect potential parent containers for batch drops. */ + const onSelectionDrag = useCallback( + (_event: React.MouseEvent, nodes: Node[]) => { + if (nodes.length === 0) return + + // Filter out nodes that can't be placed in containers + const eligibleNodes = nodes.filter((n) => { + if (n.data?.type === 'starter') return false + if (n.type === 'subflowNode') return false + const block = blocks[n.id] + if (block && TriggerUtils.isTriggerBlock(block)) return false + return true + }) + + // If no eligible nodes, clear any potential parent + if (eligibleNodes.length === 0) { + if (potentialParentId) { + clearDragHighlights() + setPotentialParentId(null) + } + return + } + + // Calculate bounding box of all dragged nodes using absolute positions + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + eligibleNodes.forEach((node) => { + const absolutePos = getNodeAbsolutePosition(node.id) + const block = blocks[node.id] + const width = BLOCK_DIMENSIONS.FIXED_WIDTH + const height = Math.max( + node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, + BLOCK_DIMENSIONS.MIN_HEIGHT + ) + + minX = Math.min(minX, absolutePos.x) + minY = Math.min(minY, absolutePos.y) + maxX = Math.max(maxX, absolutePos.x + width) + maxY = Math.max(maxY, absolutePos.y + height) + }) + + // Use bounding box for intersection detection + const selectionRect = { left: minX, right: maxX, top: minY, bottom: maxY } + + // Find containers that intersect with the selection bounding box + const allNodes = getNodes() + const intersectingContainers = allNodes + .filter((containerNode) => { + if (containerNode.type !== 'subflowNode') return false + // Skip if any dragged node is this container + if (nodes.some((n) => n.id === containerNode.id)) return false + + const containerAbsolutePos = getNodeAbsolutePosition(containerNode.id) + const containerRect = { + left: containerAbsolutePos.x, + right: + containerAbsolutePos.x + + (containerNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH), + top: containerAbsolutePos.y, + bottom: + containerAbsolutePos.y + + (containerNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), + } + + // Check intersection + return ( + selectionRect.left < containerRect.right && + selectionRect.right > containerRect.left && + selectionRect.top < containerRect.bottom && + selectionRect.bottom > containerRect.top + ) + }) + .map((n) => ({ + container: n, + depth: getNodeDepth(n.id), + size: + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH) * + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), + })) + + if (intersectingContainers.length > 0) { + // Sort by depth first (deepest first), then by size + const sortedContainers = intersectingContainers.sort((a, b) => { + if (a.depth !== b.depth) return b.depth - a.depth + return a.size - b.size + }) + + const bestMatch = sortedContainers[0] + + if (bestMatch.container.id !== potentialParentId) { + clearDragHighlights() + setPotentialParentId(bestMatch.container.id) + + // Add highlight + const containerElement = document.querySelector(`[data-id="${bestMatch.container.id}"]`) + if (containerElement) { + if ((bestMatch.container.data as SubflowNodeData)?.kind === 'loop') { + containerElement.classList.add('loop-node-drag-over') + } else if ((bestMatch.container.data as SubflowNodeData)?.kind === 'parallel') { + containerElement.classList.add('parallel-node-drag-over') + } + document.body.style.cursor = 'copy' + } + } + } else if (potentialParentId) { + clearDragHighlights() + setPotentialParentId(null) + } + }, + [ + blocks, + getNodes, + potentialParentId, + getNodeAbsolutePosition, + getNodeDepth, + clearDragHighlights, + ] + ) + const onSelectionDragStop = useCallback( (_event: React.MouseEvent, nodes: any[]) => { requestAnimationFrame(() => setIsSelectionDragActive(false)) + clearDragHighlights() if (nodes.length === 0) return const allNodes = getNodes() @@ -2592,9 +2779,55 @@ const WorkflowContent = React.memo(() => { collaborativeBatchUpdatePositions(positionUpdates, { previousPositions: multiNodeDragStartRef.current, }) + + // Process parent updates if dropping into a subflow + if (potentialParentId && potentialParentId !== dragStartParentId) { + // Filter out nodes that cannot be moved into subflows + const validNodes = nodes.filter((n: Node) => { + const block = blocks[n.id] + if (!block) return false + if (n.data?.type === 'starter') return false + if (TriggerUtils.isTriggerBlock(block)) return false + if (n.type === 'subflowNode') return false + return true + }) + + if (validNodes.length > 0) { + const updates = validNodes.map((n: Node) => { + const edgesToRemove = edgesForDisplay.filter( + (e) => e.source === n.id || e.target === n.id + ) + return { + blockId: n.id, + newParentId: potentialParentId, + affectedEdges: edgesToRemove, + } + }) + + collaborativeBatchUpdateParent(updates) + + logger.info('Batch moved selection into subflow', { + targetParentId: potentialParentId, + nodeCount: validNodes.length, + }) + } + } + + // Clear drag state + setDragStartPosition(null) + setPotentialParentId(null) multiNodeDragStartRef.current.clear() }, - [blocks, getNodes, collaborativeBatchUpdatePositions] + [ + blocks, + getNodes, + collaborativeBatchUpdatePositions, + collaborativeBatchUpdateParent, + potentialParentId, + dragStartParentId, + edgesForDisplay, + clearDragHighlights, + ] ) const onPaneClick = useCallback(() => { @@ -2818,6 +3051,8 @@ const WorkflowContent = React.memo(() => { className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`} onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined} onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined} + onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined} + onSelectionDrag={effectivePermissions.canEdit ? onSelectionDrag : undefined} onSelectionDragStop={effectivePermissions.canEdit ? onSelectionDragStop : undefined} onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined} snapToGrid={snapToGrid} diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 9825ccea06..a81ecaf670 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -924,6 +924,71 @@ export function useCollaborativeWorkflow() { [executeQueuedOperation, workflowStore] ) + const collaborativeBatchUpdateParent = useCallback( + ( + updates: Array<{ + blockId: string + newParentId: string | null + affectedEdges: Edge[] + }> + ) => { + if (!isInActiveRoom()) { + logger.debug('Skipping batch update parent - not in active workflow') + return + } + + if (updates.length === 0) return + + const batchUpdates = updates.map((u) => { + const block = workflowStore.blocks[u.blockId] + const oldParentId = block?.data?.parentId + const oldPosition = block?.position || { x: 0, y: 0 } + const newPosition = oldPosition + + return { + blockId: u.blockId, + oldParentId, + newParentId: u.newParentId || undefined, + oldPosition, + newPosition, + affectedEdges: u.affectedEdges, + } + }) + + for (const update of updates) { + if (update.affectedEdges.length > 0) { + update.affectedEdges.forEach((e) => workflowStore.removeEdge(e.id)) + } + if (update.newParentId) { + workflowStore.updateParentId(update.blockId, update.newParentId, 'parent') + } + } + + undoRedo.recordBatchUpdateParent(batchUpdates) + + const operationId = crypto.randomUUID() + addToQueue({ + id: operationId, + operation: { + operation: 'batch-update-parent', + target: 'blocks', + payload: { + updates: batchUpdates.map((u) => ({ + id: u.blockId, + parentId: u.newParentId || '', + position: u.newPosition, + })), + }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + logger.debug('Batch updated parent for blocks', { updateCount: updates.length }) + }, + [isInActiveRoom, workflowStore, undoRedo, addToQueue, activeWorkflowId, session?.user?.id] + ) + const collaborativeToggleBlockAdvancedMode = useCallback( (id: string) => { const currentBlock = workflowStore.blocks[id] @@ -1662,6 +1727,7 @@ export function useCollaborativeWorkflow() { collaborativeUpdateBlockName, collaborativeBatchToggleBlockEnabled, collaborativeUpdateParentId, + collaborativeBatchUpdateParent, collaborativeToggleBlockAdvancedMode, collaborativeToggleBlockTriggerMode, collaborativeBatchToggleBlockHandles, diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 4660ffeee2..304df71b6a 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -12,8 +12,8 @@ import { type BatchRemoveEdgesOperation, type BatchToggleEnabledOperation, type BatchToggleHandlesOperation, + type BatchUpdateParentOperation, createOperationEntry, - type Operation, runWithUndoRedoRecordingSuspended, type UpdateParentOperation, useUndoRedoStore, @@ -132,14 +132,18 @@ export function useUndoRedo() { if (!activeWorkflowId) return const edgeSnapshot = workflowStore.edges.find((e) => e.id === edgeId) + if (!edgeSnapshot) { + logger.warn('Edge not found when recording add edge', { edgeId }) + return + } - const operation: Operation = { + const operation: BatchAddEdgesOperation = { id: crypto.randomUUID(), - type: 'add-edge', + type: 'batch-add-edges', timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { edgeId }, + data: { edgeSnapshots: [edgeSnapshot] }, } const inverse: BatchRemoveEdgesOperation = { @@ -148,9 +152,7 @@ export function useUndoRedo() { timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { - edgeSnapshots: edgeSnapshot ? [edgeSnapshot] : [], - }, + data: { edgeSnapshots: [edgeSnapshot] }, } const entry = createOperationEntry(operation, inverse) @@ -297,6 +299,57 @@ export function useUndoRedo() { [activeWorkflowId, userId, undoRedoStore] ) + const recordBatchUpdateParent = useCallback( + ( + updates: Array<{ + blockId: string + oldParentId?: string + newParentId?: string + oldPosition: { x: number; y: number } + newPosition: { x: number; y: number } + affectedEdges?: Edge[] + }> + ) => { + if (!activeWorkflowId || updates.length === 0) return + + const operation: BatchUpdateParentOperation = { + id: crypto.randomUUID(), + type: 'batch-update-parent', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { updates }, + } + + const inverse: BatchUpdateParentOperation = { + id: crypto.randomUUID(), + type: 'batch-update-parent', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { + updates: updates.map((u) => ({ + blockId: u.blockId, + oldParentId: u.newParentId, + newParentId: u.oldParentId, + oldPosition: u.newPosition, + newPosition: u.oldPosition, + affectedEdges: u.affectedEdges, + })), + }, + } + + const entry = createOperationEntry(operation, inverse) + undoRedoStore.push(activeWorkflowId, userId, entry) + + logger.debug('Recorded batch update parent', { + updateCount: updates.length, + workflowId: activeWorkflowId, + }) + }, + [activeWorkflowId, userId, undoRedoStore] + ) + const recordBatchToggleEnabled = useCallback( (blockIds: string[], previousStates: Record) => { if (!activeWorkflowId || blockIds.length === 0) return @@ -493,7 +546,7 @@ export function useUndoRedo() { break } case 'batch-remove-edges': { - // Undo add-edge: inverse is batch-remove-edges, so remove the edges + // Undo batch-add-edges: inverse is batch-remove-edges, so remove the edges const batchRemoveInverse = entry.inverse as BatchRemoveEdgesOperation const { edgeSnapshots } = batchRemoveInverse.data @@ -514,7 +567,7 @@ export function useUndoRedo() { }) edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) } - logger.debug('Undid add-edge', { edgeCount: edgesToRemove.length }) + logger.debug('Undid batch-add-edges', { edgeCount: edgesToRemove.length }) break } case 'batch-add-edges': { @@ -575,12 +628,10 @@ export function useUndoRedo() { break } case 'update-parent': { - // Undo parent update means reverting to the old parent and position const updateOp = entry.inverse as UpdateParentOperation const { blockId, newParentId, newPosition, affectedEdges } = updateOp.data if (workflowStore.blocks[blockId]) { - // If we're moving back INTO a subflow, restore edges first if (newParentId && affectedEdges && affectedEdges.length > 0) { const edgesToAdd = affectedEdges.filter( (e) => !workflowStore.edges.find((edge) => edge.id === e.id) @@ -600,7 +651,6 @@ export function useUndoRedo() { } } - // Send position update to server addToQueue({ id: crypto.randomUUID(), operation: { @@ -665,6 +715,85 @@ export function useUndoRedo() { } break } + case 'batch-update-parent': { + const batchUpdateOp = entry.inverse as BatchUpdateParentOperation + const { updates } = batchUpdateOp.data + + const validUpdates = updates.filter((u) => workflowStore.blocks[u.blockId]) + if (validUpdates.length === 0) { + logger.debug('Undo batch-update-parent skipped; no blocks exist') + break + } + + // Process each update + for (const update of validUpdates) { + const { blockId, newParentId, newPosition, affectedEdges } = update + + // Moving OUT of subflow (undoing insert) → restore edges first + if (!newParentId && affectedEdges && affectedEdges.length > 0) { + const edgesToAdd = affectedEdges.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + if (edgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } + } + + // Moving INTO subflow (undoing removal) → remove edges first + if (newParentId && affectedEdges && affectedEdges.length > 0) { + affectedEdges.forEach((edge) => { + if (workflowStore.edges.find((e) => e.id === edge.id)) { + workflowStore.removeEdge(edge.id) + } + }) + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'batch-remove-edges', + target: 'edges', + payload: { edgeIds: affectedEdges.map((e) => e.id) }, + }, + workflowId: activeWorkflowId, + userId, + }) + } + + // Update position and parent locally + workflowStore.updateBlockPosition(blockId, newPosition) + workflowStore.updateParentId(blockId, newParentId || '', 'parent') + } + + // Send batch update to server + addToQueue({ + id: opId, + operation: { + operation: 'batch-update-parent', + target: 'blocks', + payload: { + updates: validUpdates.map((u) => ({ + id: u.blockId, + parentId: u.newParentId || '', + position: u.newPosition, + })), + }, + }, + workflowId: activeWorkflowId, + userId, + }) + + logger.debug('Undid batch-update-parent', { updateCount: validUpdates.length }) + break + } case 'batch-toggle-enabled': { const toggleOp = entry.inverse as BatchToggleEnabledOperation const { blockIds, previousStates } = toggleOp.data @@ -1001,29 +1130,6 @@ export function useUndoRedo() { existingBlockIds.forEach((id) => workflowStore.removeBlock(id)) break } - case 'add-edge': { - const inv = entry.inverse as BatchRemoveEdgesOperation - const edgeSnapshots = inv.data.edgeSnapshots - - const edgesToAdd = edgeSnapshots.filter( - (e) => !workflowStore.edges.find((edge) => edge.id === e.id) - ) - - if (edgesToAdd.length > 0) { - addToQueue({ - id: opId, - operation: { - operation: 'batch-add-edges', - target: 'edges', - payload: { edges: edgesToAdd }, - }, - workflowId: activeWorkflowId, - userId, - }) - edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) - } - break - } case 'batch-remove-edges': { // Redo batch-remove-edges: remove all edges again const batchRemoveOp = entry.operation as BatchRemoveEdgesOperation @@ -1199,6 +1305,85 @@ export function useUndoRedo() { } break } + case 'batch-update-parent': { + const batchUpdateOp = entry.operation as BatchUpdateParentOperation + const { updates } = batchUpdateOp.data + + const validUpdates = updates.filter((u) => workflowStore.blocks[u.blockId]) + if (validUpdates.length === 0) { + logger.debug('Redo batch-update-parent skipped; no blocks exist') + break + } + + // Process each update + for (const update of validUpdates) { + const { blockId, newParentId, newPosition, affectedEdges } = update + + // Moving INTO subflow (redoing insert) → remove edges first + if (newParentId && affectedEdges && affectedEdges.length > 0) { + affectedEdges.forEach((edge) => { + if (workflowStore.edges.find((e) => e.id === edge.id)) { + workflowStore.removeEdge(edge.id) + } + }) + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'batch-remove-edges', + target: 'edges', + payload: { edgeIds: affectedEdges.map((e) => e.id) }, + }, + workflowId: activeWorkflowId, + userId, + }) + } + + // Update position and parent locally + workflowStore.updateBlockPosition(blockId, newPosition) + workflowStore.updateParentId(blockId, newParentId || '', 'parent') + + // Moving OUT of subflow (redoing removal) → restore edges after + if (!newParentId && affectedEdges && affectedEdges.length > 0) { + const edgesToAdd = affectedEdges.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + if (edgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } + } + } + + // Send batch update to server + addToQueue({ + id: opId, + operation: { + operation: 'batch-update-parent', + target: 'blocks', + payload: { + updates: validUpdates.map((u) => ({ + id: u.blockId, + parentId: u.newParentId || '', + position: u.newPosition, + })), + }, + }, + workflowId: activeWorkflowId, + userId, + }) + + logger.debug('Redid batch-update-parent', { updateCount: validUpdates.length }) + break + } case 'batch-toggle-enabled': { const toggleOp = entry.operation as BatchToggleEnabledOperation const { blockIds, previousStates } = toggleOp.data @@ -1573,6 +1758,7 @@ export function useUndoRedo() { recordBatchRemoveEdges, recordBatchMoveBlocks, recordUpdateParent, + recordBatchUpdateParent, recordBatchToggleEnabled, recordBatchToggleHandles, recordApplyDiff, diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index cdd4ff1872..6e07e15044 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -749,6 +749,77 @@ async function handleBlocksOperationTx( break } + case 'batch-update-parent': { + const { updates } = payload + if (!Array.isArray(updates) || updates.length === 0) { + return + } + + logger.info(`Batch updating parent for ${updates.length} blocks in workflow ${workflowId}`) + + for (const update of updates) { + const { id, parentId, position } = update + if (!id) continue + + // Fetch current parent to update subflow node lists + const [existing] = await tx + .select({ + id: workflowBlocks.id, + parentId: sql`${workflowBlocks.data}->>'parentId'`, + }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (!existing) { + logger.warn(`Block ${id} not found for batch-update-parent`) + continue + } + + const isRemovingFromParent = !parentId + + // Get current data to update + const [currentBlock] = await tx + .select({ data: workflowBlocks.data }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + const currentData = currentBlock?.data || {} + + // Update data with parentId and extent + const updatedData = isRemovingFromParent + ? {} // Clear data entirely when removing from parent + : { + ...currentData, + ...(parentId ? { parentId, extent: 'parent' } : {}), + } + + // Update position and data + await tx + .update(workflowBlocks) + .set({ + positionX: position?.x ?? currentBlock?.data?.positionX, + positionY: position?.y ?? currentBlock?.data?.positionY, + data: updatedData, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) + + // If the block now has a parent, update the new parent's subflow node list + if (parentId) { + await updateSubflowNodeList(tx, workflowId, parentId) + } + // If the block had a previous parent, update that parent's node list as well + if (existing?.parentId && existing.parentId !== parentId) { + await updateSubflowNodeList(tx, workflowId, existing.parentId) + } + } + + logger.debug(`Batch updated parent for ${updates.length} blocks`) + break + } + default: throw new Error(`Unsupported blocks operation: ${operation}`) } diff --git a/apps/sim/socket/handlers/operations.ts b/apps/sim/socket/handlers/operations.ts index eaeeab3062..28d563b99d 100644 --- a/apps/sim/socket/handlers/operations.ts +++ b/apps/sim/socket/handlers/operations.ts @@ -404,6 +404,35 @@ export function setupOperationsHandlers( return } + if (target === 'blocks' && operation === 'batch-update-parent') { + 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 === 'edges' && operation === 'batch-add-edges') { await persistWorkflowOperation(workflowId, { operation, diff --git a/apps/sim/socket/middleware/permissions.ts b/apps/sim/socket/middleware/permissions.ts index 3a43010b99..5772142e20 100644 --- a/apps/sim/socket/middleware/permissions.ts +++ b/apps/sim/socket/middleware/permissions.ts @@ -20,6 +20,7 @@ const ROLE_PERMISSIONS: Record = { 'batch-remove-edges', 'batch-toggle-enabled', 'batch-toggle-handles', + 'batch-update-parent', 'update-name', 'toggle-enabled', 'update-parent', @@ -41,6 +42,7 @@ const ROLE_PERMISSIONS: Record = { 'batch-remove-edges', 'batch-toggle-enabled', 'batch-toggle-handles', + 'batch-update-parent', 'update-name', 'toggle-enabled', 'update-parent', diff --git a/apps/sim/socket/validation/schemas.ts b/apps/sim/socket/validation/schemas.ts index e1bd5f520f..13266f7808 100644 --- a/apps/sim/socket/validation/schemas.ts +++ b/apps/sim/socket/validation/schemas.ts @@ -197,6 +197,22 @@ export const BatchToggleHandlesSchema = z.object({ operationId: z.string().optional(), }) +export const BatchUpdateParentSchema = z.object({ + operation: z.literal('batch-update-parent'), + target: z.literal('blocks'), + payload: z.object({ + updates: z.array( + z.object({ + id: z.string(), + parentId: z.string(), + position: PositionSchema, + }) + ), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + export const WorkflowOperationSchema = z.union([ BlockOperationSchema, BatchPositionUpdateSchema, @@ -204,6 +220,7 @@ export const WorkflowOperationSchema = z.union([ BatchRemoveBlocksSchema, BatchToggleEnabledSchema, BatchToggleHandlesSchema, + BatchUpdateParentSchema, EdgeOperationSchema, BatchAddEdgesSchema, BatchRemoveEdgesSchema, diff --git a/apps/sim/stores/undo-redo/store.test.ts b/apps/sim/stores/undo-redo/store.test.ts index 3f8a5a3c07..add8625961 100644 --- a/apps/sim/stores/undo-redo/store.test.ts +++ b/apps/sim/stores/undo-redo/store.test.ts @@ -596,7 +596,7 @@ describe('useUndoRedoStore', () => { expect(getStackSizes(workflowId, userId).undoSize).toBe(2) const entry = undo(workflowId, userId) - expect(entry?.operation.type).toBe('add-edge') + expect(entry?.operation.type).toBe('batch-add-edges') expect(getStackSizes(workflowId, userId).redoSize).toBe(1) redo(workflowId, userId) diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts index 3aabbd8558..881e26ee80 100644 --- a/apps/sim/stores/undo-redo/store.ts +++ b/apps/sim/stores/undo-redo/store.ts @@ -8,6 +8,7 @@ import type { BatchMoveBlocksOperation, BatchRemoveBlocksOperation, BatchRemoveEdgesOperation, + BatchUpdateParentOperation, Operation, OperationEntry, UndoRedoState, @@ -101,6 +102,10 @@ function isOperationApplicable( const blockId = operation.data.blockId return Boolean(graph.blocksById[blockId]) } + case 'batch-update-parent': { + const op = operation as BatchUpdateParentOperation + return op.data.updates.every((u) => Boolean(graph.blocksById[u.blockId])) + } case 'batch-remove-edges': { const op = operation as BatchRemoveEdgesOperation return op.data.edgeSnapshots.every((edge) => Boolean(graph.edgesById[edge.id])) @@ -109,10 +114,6 @@ function isOperationApplicable( const op = operation as BatchAddEdgesOperation return op.data.edgeSnapshots.every((edge) => !graph.edgesById[edge.id]) } - case 'add-edge': { - const edgeId = operation.data.edgeId - return !graph.edgesById[edgeId] - } default: return true } diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index e32b793447..53147d7e7c 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -4,11 +4,11 @@ import type { BlockState } from '@/stores/workflows/workflow/types' export type OperationType = | 'batch-add-blocks' | 'batch-remove-blocks' - | 'add-edge' | 'batch-add-edges' | 'batch-remove-edges' | 'batch-move-blocks' | 'update-parent' + | 'batch-update-parent' | 'batch-toggle-enabled' | 'batch-toggle-handles' | 'apply-diff' @@ -41,13 +41,6 @@ export interface BatchRemoveBlocksOperation extends BaseOperation { } } -export interface AddEdgeOperation extends BaseOperation { - type: 'add-edge' - data: { - edgeId: string - } -} - export interface BatchAddEdgesOperation extends BaseOperation { type: 'batch-add-edges' data: { @@ -85,6 +78,20 @@ export interface UpdateParentOperation extends BaseOperation { } } +export interface BatchUpdateParentOperation extends BaseOperation { + type: 'batch-update-parent' + data: { + updates: Array<{ + blockId: string + oldParentId?: string + newParentId?: string + oldPosition: { x: number; y: number } + newPosition: { x: number; y: number } + affectedEdges?: Edge[] + }> + } +} + export interface BatchToggleEnabledOperation extends BaseOperation { type: 'batch-toggle-enabled' data: { @@ -133,11 +140,11 @@ export interface RejectDiffOperation extends BaseOperation { export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation - | AddEdgeOperation | BatchAddEdgesOperation | BatchRemoveEdgesOperation | BatchMoveBlocksOperation | UpdateParentOperation + | BatchUpdateParentOperation | BatchToggleEnabledOperation | BatchToggleHandlesOperation | ApplyDiffOperation diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index aef24740a4..d56604be2d 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -4,6 +4,7 @@ import type { BatchMoveBlocksOperation, BatchRemoveBlocksOperation, BatchRemoveEdgesOperation, + BatchUpdateParentOperation, Operation, OperationEntry, } from '@/stores/undo-redo/types' @@ -45,18 +46,6 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchAddBlocksOperation } - case 'add-edge': - // Note: add-edge only stores edgeId. The full edge snapshot is stored - // in the inverse operation when recording. This function can't create - // a complete inverse without the snapshot. - return { - ...operation, - type: 'batch-remove-edges', - data: { - edgeSnapshots: [], - }, - } as BatchRemoveEdgesOperation - case 'batch-add-edges': { const op = operation as BatchAddEdgesOperation return { @@ -107,6 +96,23 @@ export function createInverseOperation(operation: Operation): Operation { }, } + case 'batch-update-parent': { + const op = operation as BatchUpdateParentOperation + return { + ...operation, + data: { + updates: op.data.updates.map((u) => ({ + blockId: u.blockId, + oldParentId: u.newParentId, + newParentId: u.oldParentId, + oldPosition: u.newPosition, + newPosition: u.oldPosition, + affectedEdges: u.affectedEdges, + })), + }, + } as BatchUpdateParentOperation + } + case 'apply-diff': return { ...operation, diff --git a/packages/testing/src/factories/index.ts b/packages/testing/src/factories/index.ts index 0eb9e6f5ca..2fafe98625 100644 --- a/packages/testing/src/factories/index.ts +++ b/packages/testing/src/factories/index.ts @@ -120,16 +120,17 @@ export { } from './serialized-block.factory' // Undo/redo operation factories export { - type AddEdgeOperation, type BaseOperation, type BatchAddBlocksOperation, type BatchAddEdgesOperation, type BatchMoveBlocksOperation, type BatchRemoveBlocksOperation, type BatchRemoveEdgesOperation, + type BatchUpdateParentOperation, createAddBlockEntry, createAddEdgeEntry, createBatchRemoveEdgesEntry, + createBatchUpdateParentEntry, createMoveBlockEntry, createRemoveBlockEntry, createUpdateParentEntry, @@ -138,7 +139,6 @@ export { type OperationType, type UpdateParentOperation, } from './undo-redo.factory' -// User/workspace factories export { createUser, createUserWithWorkspace, @@ -148,7 +148,6 @@ export { type WorkflowObjectFactoryOptions, type WorkspaceFactoryOptions, } from './user.factory' -// Workflow factories export { createBranchingWorkflow, createLinearWorkflow, diff --git a/packages/testing/src/factories/undo-redo.factory.ts b/packages/testing/src/factories/undo-redo.factory.ts index ac2432d85b..86c26ef927 100644 --- a/packages/testing/src/factories/undo-redo.factory.ts +++ b/packages/testing/src/factories/undo-redo.factory.ts @@ -8,11 +8,11 @@ import { nanoid } from 'nanoid' export type OperationType = | 'batch-add-blocks' | 'batch-remove-blocks' - | 'add-edge' | 'batch-add-edges' | 'batch-remove-edges' | 'batch-move-blocks' | 'update-parent' + | 'batch-update-parent' /** * Base operation interface. @@ -63,14 +63,6 @@ export interface BatchRemoveBlocksOperation extends BaseOperation { } } -/** - * Add edge operation data. - */ -export interface AddEdgeOperation extends BaseOperation { - type: 'add-edge' - data: { edgeId: string } -} - /** * Batch add edges operation data. */ @@ -101,14 +93,28 @@ export interface UpdateParentOperation extends BaseOperation { } } +export interface BatchUpdateParentOperation extends BaseOperation { + type: 'batch-update-parent' + data: { + updates: Array<{ + blockId: string + oldParentId?: string + newParentId?: string + oldPosition: { x: number; y: number } + newPosition: { x: number; y: number } + affectedEdges?: any[] + }> + } +} + export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation - | AddEdgeOperation | BatchAddEdgesOperation | BatchRemoveEdgesOperation | BatchMoveBlocksOperation | UpdateParentOperation + | BatchUpdateParentOperation /** * Operation entry with forward and inverse operations. @@ -220,7 +226,7 @@ export function createRemoveBlockEntry( } /** - * Creates a mock add-edge operation entry. + * Creates a mock batch-add-edges operation entry for a single edge. */ export function createAddEdgeEntry( edgeId: string, @@ -237,11 +243,11 @@ export function createAddEdgeEntry( createdAt, operation: { id: nanoid(8), - type: 'add-edge', + type: 'batch-add-edges', timestamp, workflowId, userId, - data: { edgeId }, + data: { edgeSnapshots: [snapshot] }, }, inverse: { id: nanoid(8), @@ -378,3 +384,75 @@ export function createUpdateParentEntry( }, } } + +interface BatchUpdateParentOptions extends OperationEntryOptions { + updates?: Array<{ + blockId: string + oldParentId?: string + newParentId?: string + oldPosition?: { x: number; y: number } + newPosition?: { x: number; y: number } + affectedEdges?: any[] + }> +} + +/** + * Creates a mock batch-update-parent operation entry. + */ +export function createBatchUpdateParentEntry(options: BatchUpdateParentOptions = {}): any { + const { + id = nanoid(8), + workflowId = 'wf-1', + userId = 'user-1', + createdAt = Date.now(), + updates = [ + { + blockId: 'block-1', + oldParentId: undefined, + newParentId: 'loop-1', + oldPosition: { x: 0, y: 0 }, + newPosition: { x: 50, y: 50 }, + }, + ], + } = options + const timestamp = Date.now() + + const processedUpdates = updates.map((u) => ({ + blockId: u.blockId, + oldParentId: u.oldParentId, + newParentId: u.newParentId, + oldPosition: u.oldPosition || { x: 0, y: 0 }, + newPosition: u.newPosition || { x: 50, y: 50 }, + affectedEdges: u.affectedEdges, + })) + + return { + id, + createdAt, + operation: { + id: nanoid(8), + type: 'batch-update-parent', + timestamp, + workflowId, + userId, + data: { updates: processedUpdates }, + }, + inverse: { + id: nanoid(8), + type: 'batch-update-parent', + timestamp, + workflowId, + userId, + data: { + updates: processedUpdates.map((u) => ({ + blockId: u.blockId, + oldParentId: u.newParentId, + newParentId: u.oldParentId, + oldPosition: u.newPosition, + newPosition: u.oldPosition, + affectedEdges: u.affectedEdges, + })), + }, + }, + } +} From 6e7f3dadbfbae96e0460248ebefc50f91c8f383f Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 21:15:16 -0800 Subject: [PATCH 10/35] keep edges on subflow actions intact --- apps/sim/app/_styles/globals.css | 4 +- .../w/[workflowId]/hooks/index.ts | 1 + .../utils/workflow-canvas-helpers.ts | 42 +++++- .../[workspaceId]/w/[workflowId]/workflow.tsx | 127 +++++++++++++----- 4 files changed, 139 insertions(+), 35 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 4123df565c..eaac62a570 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -51,9 +51,11 @@ border: 1px solid var(--brand-secondary) !important; } -.react-flow__nodesselection-rect { +.react-flow__nodesselection-rect, +.react-flow__nodesselection { background: transparent !important; border: none !important; + pointer-events: none !important; } /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts index 65bd3d4e49..3af268aa37 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts @@ -1,6 +1,7 @@ export { clearDragHighlights, computeClampedPositionUpdates, + computeParentUpdateEntries, getClampedPositionForNode, isInEditableElement, selectNodesDeferred, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts index 634aedb326..a0f2a57722 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -1,4 +1,4 @@ -import type { Node } from 'reactflow' +import type { Edge, Node } from 'reactflow' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' @@ -139,3 +139,43 @@ export function computeClampedPositionUpdates( position: getClampedPositionForNode(node.id, node.position, blocks, allNodes), })) } + +interface ParentUpdateEntry { + blockId: string + newParentId: string + affectedEdges: Edge[] +} + +/** + * Computes parent update entries for nodes being moved into a subflow. + * Only includes "boundary edges" - edges that cross the selection boundary + * (one end inside selection, one end outside). Edges between nodes in the + * selection are preserved. + */ +export function computeParentUpdateEntries( + validNodes: Node[], + allEdges: Edge[], + targetParentId: string +): ParentUpdateEntry[] { + const movingNodeIds = new Set(validNodes.map((n) => n.id)) + + // Find edges that cross the boundary (one end inside selection, one end outside) + // Edges between nodes in the selection should stay intact + const boundaryEdges = allEdges.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + // Only remove if exactly one end is in the selection (crosses boundary) + return sourceInSelection !== targetInSelection + }) + + // Build updates for all valid nodes + return validNodes.map((n) => { + // Only include boundary edges connected to this specific node + const edgesForThisNode = boundaryEdges.filter((e) => e.source === n.id || e.target === n.id) + return { + blockId: n.id, + newParentId: targetParentId, + affectedEdges: edgesForThisNode, + } + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index db0462a0ca..40bbd614c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2273,9 +2273,6 @@ const WorkflowContent = React.memo(() => { // Only consider container nodes that aren't the dragged node if (n.type !== 'subflowNode' || n.id === node.id) return false - // Skip if this container is already the parent of the node being dragged - if (n.id === currentParentId) return false - // Get the container's absolute position const containerAbsolutePos = getNodeAbsolutePosition(n.id) @@ -2426,37 +2423,56 @@ const WorkflowContent = React.memo(() => { previousPositions: multiNodeDragStartRef.current, }) - // Process parent updates for all selected nodes if dropping into a subflow - if (potentialParentId && potentialParentId !== dragStartParentId) { - // Filter out nodes that cannot be moved into subflows - const validNodes = selectedNodes.filter((n) => { - const block = blocks[n.id] - if (!block) return false - // Starter blocks cannot be in containers - if (n.data?.type === 'starter') return false - // Trigger blocks cannot be in containers - if (TriggerUtils.isTriggerBlock(block)) return false - // Subflow nodes (loop/parallel) cannot be nested - if (n.type === 'subflowNode') return false + // Process parent updates for nodes whose parent is changing + // Check each node individually - don't rely on dragStartParentId since + // multi-node selections can contain nodes from different parents + const selectedNodeIds = new Set(selectedNodes.map((n) => n.id)) + const nodesNeedingParentUpdate = selectedNodes.filter((n) => { + const block = blocks[n.id] + if (!block) return false + const currentParent = block.data?.parentId || null + // Skip if the node's parent is also being moved (keep children with their parent) + if (currentParent && selectedNodeIds.has(currentParent)) return false + // Node needs update if current parent !== target parent + return currentParent !== potentialParentId + }) + + if (nodesNeedingParentUpdate.length > 0) { + // Filter out nodes that cannot be moved into subflows (when target is a subflow) + const validNodes = nodesNeedingParentUpdate.filter((n) => { + // These restrictions only apply when moving INTO a subflow + if (potentialParentId) { + if (n.data?.type === 'starter') return false + const block = blocks[n.id] + if (block && TriggerUtils.isTriggerBlock(block)) return false + if (n.type === 'subflowNode') return false + } return true }) if (validNodes.length > 0) { - // Build updates for all valid nodes + // Use boundary edge logic - only remove edges crossing the boundary + const movingNodeIds = new Set(validNodes.map((n) => n.id)) + const boundaryEdges = edgesForDisplay.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + return sourceInSelection !== targetInSelection + }) + const updates = validNodes.map((n) => { - const edgesToRemove = edgesForDisplay.filter( + const edgesForThisNode = boundaryEdges.filter( (e) => e.source === n.id || e.target === n.id ) return { blockId: n.id, newParentId: potentialParentId, - affectedEdges: edgesToRemove, + affectedEdges: edgesForThisNode, } }) collaborativeBatchUpdateParent(updates) - logger.info('Batch moved nodes into subflow', { + logger.info('Batch moved nodes to new parent', { targetParentId: potentialParentId, nodeCount: validNodes.length, }) @@ -2584,6 +2600,30 @@ const WorkflowContent = React.memo(() => { edgesToAdd.forEach((edge) => addEdge(edge)) window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } })) + } else if (!potentialParentId && dragStartParentId) { + // Moving OUT of a subflow to canvas + // Remove edges connected to this node since it's leaving its parent + const edgesToRemove = edgesForDisplay.filter( + (e) => e.source === node.id || e.target === node.id + ) + + if (edgesToRemove.length > 0) { + removeEdgesForNode(node.id, edgesToRemove) + + logger.info('Removed edges when moving node out of subflow', { + blockId: node.id, + sourceParentId: dragStartParentId, + edgeCount: edgesToRemove.length, + }) + } + + // Clear the parent relationship + updateNodeParent(node.id, null, edgesToRemove) + + logger.info('Moved node out of subflow', { + blockId: node.id, + sourceParentId: dragStartParentId, + }) } // Reset state @@ -2780,33 +2820,56 @@ const WorkflowContent = React.memo(() => { previousPositions: multiNodeDragStartRef.current, }) - // Process parent updates if dropping into a subflow - if (potentialParentId && potentialParentId !== dragStartParentId) { - // Filter out nodes that cannot be moved into subflows - const validNodes = nodes.filter((n: Node) => { - const block = blocks[n.id] - if (!block) return false - if (n.data?.type === 'starter') return false - if (TriggerUtils.isTriggerBlock(block)) return false - if (n.type === 'subflowNode') return false + // Process parent updates for nodes whose parent is changing + // Check each node individually - don't rely on dragStartParentId since + // multi-node selections can contain nodes from different parents + const selectedNodeIds = new Set(nodes.map((n: Node) => n.id)) + const nodesNeedingParentUpdate = nodes.filter((n: Node) => { + const block = blocks[n.id] + if (!block) return false + const currentParent = block.data?.parentId || null + // Skip if the node's parent is also being moved (keep children with their parent) + if (currentParent && selectedNodeIds.has(currentParent)) return false + // Node needs update if current parent !== target parent + return currentParent !== potentialParentId + }) + + if (nodesNeedingParentUpdate.length > 0) { + // Filter out nodes that cannot be moved into subflows (when target is a subflow) + const validNodes = nodesNeedingParentUpdate.filter((n: Node) => { + // These restrictions only apply when moving INTO a subflow + if (potentialParentId) { + if (n.data?.type === 'starter') return false + const block = blocks[n.id] + if (block && TriggerUtils.isTriggerBlock(block)) return false + if (n.type === 'subflowNode') return false + } return true }) if (validNodes.length > 0) { + // Use boundary edge logic - only remove edges crossing the boundary + const movingNodeIds = new Set(validNodes.map((n: Node) => n.id)) + const boundaryEdges = edgesForDisplay.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + return sourceInSelection !== targetInSelection + }) + const updates = validNodes.map((n: Node) => { - const edgesToRemove = edgesForDisplay.filter( + const edgesForThisNode = boundaryEdges.filter( (e) => e.source === n.id || e.target === n.id ) return { blockId: n.id, newParentId: potentialParentId, - affectedEdges: edgesToRemove, + affectedEdges: edgesForThisNode, } }) collaborativeBatchUpdateParent(updates) - logger.info('Batch moved selection into subflow', { + logger.info('Batch moved selection to new parent', { targetParentId: potentialParentId, nodeCount: validNodes.length, }) @@ -2824,7 +2887,6 @@ const WorkflowContent = React.memo(() => { collaborativeBatchUpdatePositions, collaborativeBatchUpdateParent, potentialParentId, - dragStartParentId, edgesForDisplay, clearDragHighlights, ] @@ -2909,7 +2971,6 @@ const WorkflowContent = React.memo(() => { /** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */ const edgesWithSelection = useMemo(() => { - // Build node lookup map once - O(n) instead of O(n) per edge const nodeMap = new Map(displayNodes.map((n) => [n.id, n])) return edgesForDisplay.map((edge) => { From 4fa6cb84c6a95bfff7369a047631aa0921688bfc Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 8 Jan 2026 21:34:36 -0800 Subject: [PATCH 11/35] fix subflow resizing --- .../[workflowId]/hooks/use-node-utilities.ts | 79 +++++++++--------- .../[workspaceId]/w/[workflowId]/workflow.tsx | 80 ++++++++++++++++++- apps/sim/hooks/use-collaborative-workflow.ts | 5 +- 3 files changed, 119 insertions(+), 45 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index 77fb9cd7c9..ffa148d881 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { useReactFlow } from 'reactflow' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { getBlock } from '@/blocks/registry' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('NodeUtilities') @@ -208,28 +209,30 @@ export function useNodeUtilities(blocks: Record) { * to the content area bounds (after header and padding). * @param nodeId ID of the node being repositioned * @param newParentId ID of the new parent + * @param skipClamping If true, returns raw relative position without clamping to container bounds * @returns Relative position coordinates {x, y} within the parent */ const calculateRelativePosition = useCallback( - (nodeId: string, newParentId: string): { x: number; y: number } => { + (nodeId: string, newParentId: string, skipClamping?: boolean): { x: number; y: number } => { const nodeAbsPos = getNodeAbsolutePosition(nodeId) const parentAbsPos = getNodeAbsolutePosition(newParentId) - const parentNode = getNodes().find((n) => n.id === newParentId) - // Calculate raw relative position (relative to parent origin) const rawPosition = { x: nodeAbsPos.x - parentAbsPos.x, y: nodeAbsPos.y - parentAbsPos.y, } - // Get container and block dimensions + if (skipClamping) { + return rawPosition + } + + const parentNode = getNodes().find((n) => n.id === newParentId) const containerDimensions = { width: parentNode?.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, height: parentNode?.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, } const blockDimensions = getBlockDimensions(nodeId) - // Clamp position to keep block inside content area return clampPositionToContainer(rawPosition, containerDimensions, blockDimensions) }, [getNodeAbsolutePosition, getNodes, getBlockDimensions] @@ -298,12 +301,12 @@ export function useNodeUtilities(blocks: Record) { */ const calculateLoopDimensions = useCallback( (nodeId: string): { width: number; height: number } => { - // Check both React Flow's node.parentId AND blocks store's data.parentId - // This ensures we catch children even if React Flow hasn't re-rendered yet - const childNodes = getNodes().filter( - (node) => node.parentId === nodeId || blocks[node.id]?.data?.parentId === nodeId + const currentBlocks = useWorkflowStore.getState().blocks + const childBlockIds = Object.keys(currentBlocks).filter( + (id) => currentBlocks[id]?.data?.parentId === nodeId ) - if (childNodes.length === 0) { + + if (childBlockIds.length === 0) { return { width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, @@ -313,30 +316,28 @@ export function useNodeUtilities(blocks: Record) { let maxRight = 0 let maxBottom = 0 - childNodes.forEach((node) => { - const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id) - // Use ReactFlow's node.position which is already in the correct coordinate system - // (relative to parent for child nodes). The store's block.position may be stale - // or still in absolute coordinates during parent updates. - maxRight = Math.max(maxRight, node.position.x + nodeWidth) - maxBottom = Math.max(maxBottom, node.position.y + nodeHeight) - }) + for (const childId of childBlockIds) { + const child = currentBlocks[childId] + if (!child?.position) continue + + const { width: childWidth, height: childHeight } = getBlockDimensions(childId) + + maxRight = Math.max(maxRight, child.position.x + childWidth) + maxBottom = Math.max(maxBottom, child.position.y + childHeight) + } const width = Math.max( CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING ) const height = Math.max( CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - CONTAINER_DIMENSIONS.HEADER_HEIGHT + - CONTAINER_DIMENSIONS.TOP_PADDING + - maxBottom + - CONTAINER_DIMENSIONS.BOTTOM_PADDING + maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING ) return { width, height } }, - [getNodes, getBlockDimensions, blocks] + [getBlockDimensions] ) /** @@ -345,29 +346,27 @@ export function useNodeUtilities(blocks: Record) { */ const resizeLoopNodes = useCallback( (updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void) => { - const containerNodes = getNodes() - .filter((node) => node.type && isContainerType(node.type)) - .map((node) => ({ - ...node, - depth: getNodeDepth(node.id), + const currentBlocks = useWorkflowStore.getState().blocks + const containerBlocks = Object.entries(currentBlocks) + .filter(([, block]) => block?.type && isContainerType(block.type)) + .map(([id, block]) => ({ + id, + block, + depth: getNodeDepth(id), })) - // Sort by depth descending - process innermost containers first - // so their dimensions are correct when outer containers calculate sizes .sort((a, b) => b.depth - a.depth) - containerNodes.forEach((node) => { - const dimensions = calculateLoopDimensions(node.id) - // Get current dimensions from the blocks store rather than React Flow's potentially stale state - const currentWidth = blocks[node.id]?.data?.width - const currentHeight = blocks[node.id]?.data?.height + for (const { id, block } of containerBlocks) { + const dimensions = calculateLoopDimensions(id) + const currentWidth = block?.data?.width + const currentHeight = block?.data?.height - // Only update if dimensions actually changed to avoid unnecessary re-renders if (dimensions.width !== currentWidth || dimensions.height !== currentHeight) { - updateNodeDimensions(node.id, dimensions) + updateNodeDimensions(id, dimensions) } - }) + } }, - [getNodes, isContainerType, getNodeDepth, calculateLoopDimensions, blocks] + [isContainerType, getNodeDepth, calculateLoopDimensions] ) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index db0462a0ca..4b1ed19e4c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -303,6 +303,7 @@ const WorkflowContent = React.memo(() => { const { getNodeDepth, getNodeAbsolutePosition, + calculateRelativePosition, isPointInLoopNode, resizeLoopNodes, updateNodeParent: updateNodeParentUtil, @@ -2442,20 +2443,54 @@ const WorkflowContent = React.memo(() => { }) if (validNodes.length > 0) { - // Build updates for all valid nodes - const updates = validNodes.map((n) => { + const rawUpdates = validNodes.map((n) => { const edgesToRemove = edgesForDisplay.filter( (e) => e.source === n.id || e.target === n.id ) + const newPosition = calculateRelativePosition(n.id, potentialParentId, true) return { blockId: n.id, newParentId: potentialParentId, + newPosition, affectedEdges: edgesToRemove, } }) + const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x)) + const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y)) + + const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING + const targetMinY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING + + const shiftX = minX < targetMinX ? targetMinX - minX : 0 + const shiftY = minY < targetMinY ? targetMinY - minY : 0 + + const updates = rawUpdates.map((u) => ({ + ...u, + newPosition: { + x: u.newPosition.x + shiftX, + y: u.newPosition.y + shiftY, + }, + })) + collaborativeBatchUpdateParent(updates) + setDisplayNodes((nodes) => + nodes.map((node) => { + const update = updates.find((u) => u.blockId === node.id) + if (update) { + return { + ...node, + position: update.newPosition, + parentId: update.newParentId, + } + } + return node + }) + ) + + resizeLoopNodesWrapper() + logger.info('Batch moved nodes into subflow', { targetParentId: potentialParentId, nodeCount: validNodes.length, @@ -2601,6 +2636,8 @@ const WorkflowContent = React.memo(() => { edgesForDisplay, removeEdgesForNode, getNodeAbsolutePosition, + calculateRelativePosition, + resizeLoopNodesWrapper, getDragStartPosition, setDragStartPosition, addNotification, @@ -2793,19 +2830,54 @@ const WorkflowContent = React.memo(() => { }) if (validNodes.length > 0) { - const updates = validNodes.map((n: Node) => { + const rawUpdates = validNodes.map((n: Node) => { const edgesToRemove = edgesForDisplay.filter( (e) => e.source === n.id || e.target === n.id ) + const newPosition = calculateRelativePosition(n.id, potentialParentId, true) return { blockId: n.id, newParentId: potentialParentId, + newPosition, affectedEdges: edgesToRemove, } }) + const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x)) + const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y)) + + const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING + const targetMinY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING + + const shiftX = minX < targetMinX ? targetMinX - minX : 0 + const shiftY = minY < targetMinY ? targetMinY - minY : 0 + + const updates = rawUpdates.map((u) => ({ + ...u, + newPosition: { + x: u.newPosition.x + shiftX, + y: u.newPosition.y + shiftY, + }, + })) + collaborativeBatchUpdateParent(updates) + setDisplayNodes((nodes) => + nodes.map((node) => { + const update = updates.find((u) => u.blockId === node.id) + if (update) { + return { + ...node, + position: update.newPosition, + parentId: update.newParentId, + } + } + return node + }) + ) + + resizeLoopNodesWrapper() + logger.info('Batch moved selection into subflow', { targetParentId: potentialParentId, nodeCount: validNodes.length, @@ -2823,6 +2895,8 @@ const WorkflowContent = React.memo(() => { getNodes, collaborativeBatchUpdatePositions, collaborativeBatchUpdateParent, + calculateRelativePosition, + resizeLoopNodesWrapper, potentialParentId, dragStartParentId, edgesForDisplay, diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index a81ecaf670..2bd9ce819a 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -929,6 +929,7 @@ export function useCollaborativeWorkflow() { updates: Array<{ blockId: string newParentId: string | null + newPosition: { x: number; y: number } affectedEdges: Edge[] }> ) => { @@ -943,14 +944,13 @@ export function useCollaborativeWorkflow() { const block = workflowStore.blocks[u.blockId] const oldParentId = block?.data?.parentId const oldPosition = block?.position || { x: 0, y: 0 } - const newPosition = oldPosition return { blockId: u.blockId, oldParentId, newParentId: u.newParentId || undefined, oldPosition, - newPosition, + newPosition: u.newPosition, affectedEdges: u.affectedEdges, } }) @@ -959,6 +959,7 @@ export function useCollaborativeWorkflow() { if (update.affectedEdges.length > 0) { update.affectedEdges.forEach((e) => workflowStore.removeEdge(e.id)) } + workflowStore.updateBlockPosition(update.blockId, update.newPosition) if (update.newParentId) { workflowStore.updateParentId(update.blockId, update.newParentId, 'parent') } From b41d17c11b37f5edd7c9812d6b0498b76aec3510 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 8 Jan 2026 21:52:32 -0800 Subject: [PATCH 12/35] fix remove from subflow bulk --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index c35370f5ff..6503a747aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -748,13 +748,16 @@ const WorkflowContent = React.memo(() => { }, [contextMenuBlocks, collaborativeBatchToggleBlockHandles]) const handleContextRemoveFromSubflow = useCallback(() => { - contextMenuBlocks.forEach((block) => { - if (block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')) { - window.dispatchEvent( - new CustomEvent('remove-from-subflow', { detail: { blockId: block.id } }) - ) - } - }) + const blocksToRemove = contextMenuBlocks.filter( + (block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel') + ) + if (blocksToRemove.length > 0) { + window.dispatchEvent( + new CustomEvent('remove-from-subflow', { + detail: { blockIds: blocksToRemove.map((b) => b.id) }, + }) + ) + } }, [contextMenuBlocks]) const handleContextOpenEditor = useCallback(() => { @@ -921,20 +924,32 @@ const WorkflowContent = React.memo(() => { /** Handles ActionBar remove-from-subflow events. */ useEffect(() => { const handleRemoveFromSubflow = (event: Event) => { - const customEvent = event as CustomEvent<{ blockId: string }> - const blockId = customEvent.detail?.blockId - if (!blockId) return + const customEvent = event as CustomEvent<{ blockIds: string[] }> + const blockIds = customEvent.detail?.blockIds + if (!blockIds || blockIds.length === 0) return try { - const currentBlock = blocks[blockId] - const parentId = currentBlock?.data?.parentId - if (!parentId) return + const validBlockIds = blockIds.filter((id) => { + const block = blocks[id] + return block?.data?.parentId + }) + if (validBlockIds.length === 0) return - const edgesToRemove = edgesForDisplay.filter( - (e) => e.source === blockId || e.target === blockId - ) - removeEdgesForNode(blockId, edgesToRemove) - updateNodeParent(blockId, null, edgesToRemove) + const movingNodeIds = new Set(validBlockIds) + + const boundaryEdges = edgesForDisplay.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + return sourceInSelection !== targetInSelection + }) + + for (const blockId of validBlockIds) { + const edgesForThisNode = boundaryEdges.filter( + (e) => e.source === blockId || e.target === blockId + ) + removeEdgesForNode(blockId, edgesForThisNode) + updateNodeParent(blockId, null, edgesForThisNode) + } } catch (err) { logger.error('Failed to remove from subflow', { err }) } From 0f515c32037d76678045a3fc159e113892ec4048 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 18:04:36 -0800 Subject: [PATCH 13/35] improvement(canvas): add multi-block select, add batch handle, enabled, and edge operations --- apps/sim/app/(landing)/privacy/page.tsx | 2 +- apps/sim/app/_styles/globals.css | 32 + .../w/[workflowId]/components/chat/chat.tsx | 2 +- .../toolbar/components/drag-preview.ts | 5 +- .../components/terminal/terminal.tsx | 6 +- .../components/action-bar/action-bar.tsx | 8 +- .../hooks/{use-float => float}/index.ts | 0 .../use-float-boundary-sync.ts | 0 .../{use-float => float}/use-float-drag.ts | 0 .../{use-float => float}/use-float-resize.ts | 0 .../w/[workflowId]/hooks/index.ts | 10 +- .../w/[workflowId]/hooks/use-block-visual.ts | 7 +- .../w/[workflowId]/utils/block-ring-utils.ts | 16 +- .../utils/workflow-canvas-helpers.ts | 141 +++++ .../[workspaceId]/w/[workflowId]/workflow.tsx | 367 ++++++----- .../emails/components/email-footer.tsx | 2 +- apps/sim/hooks/use-collaborative-workflow.ts | 223 +++++-- apps/sim/hooks/use-undo-redo.ts | 577 ++++++++++++------ apps/sim/socket/database/operations.ts | 119 ++++ apps/sim/socket/handlers/operations.ts | 116 ++++ apps/sim/socket/middleware/permissions.ts | 8 + apps/sim/socket/validation/schemas.ts | 55 +- apps/sim/stores/undo-redo/store.test.ts | 2 +- apps/sim/stores/undo-redo/store.ts | 104 ++-- apps/sim/stores/undo-redo/types.ts | 55 +- apps/sim/stores/undo-redo/utils.ts | 103 +++- packages/testing/src/factories/index.ts | 2 +- .../src/factories/undo-redo.factory.ts | 28 +- 28 files changed, 1432 insertions(+), 558 deletions(-) rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/{use-float => float}/index.ts (100%) rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/{use-float => float}/use-float-boundary-sync.ts (100%) rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/{use-float => float}/use-float-drag.ts (100%) rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/{use-float => float}/use-float-resize.ts (100%) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts diff --git a/apps/sim/app/(landing)/privacy/page.tsx b/apps/sim/app/(landing)/privacy/page.tsx index 7f6a0ee7d6..a32e2b980c 100644 --- a/apps/sim/app/(landing)/privacy/page.tsx +++ b/apps/sim/app/(landing)/privacy/page.tsx @@ -767,7 +767,7 @@ export default function PrivacyPolicy() { privacy@sim.ai -
  • Mailing Address: Sim, 80 Langton St, San Francisco, CA 94133, USA
  • +
  • Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA
  • We will respond to your request within a reasonable timeframe.

    diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index b94f9a2e58..4123df565c 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -42,6 +42,38 @@ animation: dash-animation 1.5s linear infinite !important; } +/** + * React Flow selection box styling + * Uses brand-secondary color for selection highlighting + */ +.react-flow__selection { + background: rgba(51, 180, 255, 0.08) !important; + border: 1px solid var(--brand-secondary) !important; +} + +.react-flow__nodesselection-rect { + background: transparent !important; + border: none !important; +} + +/** + * Selected node ring indicator + * Uses a pseudo-element overlay to match the original behavior (absolute inset-0 z-40) + */ +.react-flow__node.selected > div > div { + position: relative; +} + +.react-flow__node.selected > div > div::after { + content: ""; + position: absolute; + inset: 0; + z-index: 40; + border-radius: 8px; + box-shadow: 0 0 0 1.75px var(--brand-secondary); + pointer-events: none; +} + /** * Color tokens - single source of truth for all colors * Light mode: Warm theme diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 5af836d845..d46eab0f2d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -45,7 +45,7 @@ import { useFloatBoundarySync, useFloatDrag, useFloatResize, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float' +} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import type { BlockLog, ExecutionResult } from '@/executor/types' import { getChatPosition, useChatStore } from '@/stores/chat/store' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts index 1ea9bf4e86..e79663e5c0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components/drag-preview.ts @@ -32,7 +32,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement { z-index: 9999; ` - // Create icon container const iconContainer = document.createElement('div') iconContainer.style.cssText = ` width: 24px; @@ -45,7 +44,6 @@ export function createDragPreview(info: DragItemInfo): HTMLElement { flex-shrink: 0; ` - // Clone the actual icon if provided if (info.iconElement) { const clonedIcon = info.iconElement.cloneNode(true) as HTMLElement clonedIcon.style.width = '16px' @@ -55,11 +53,10 @@ export function createDragPreview(info: DragItemInfo): HTMLElement { iconContainer.appendChild(clonedIcon) } - // Create text element const text = document.createElement('span') text.textContent = info.name text.style.cssText = ` - color: #FFFFFF; + color: var(--text-primary); font-size: 16px; font-weight: 500; white-space: nowrap; diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index b63ce2d3b4..be94da2619 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -1489,9 +1489,7 @@ export function Terminal() { variant='ghost' className={clsx( 'px-[8px] py-[6px] text-[12px]', - !showInput && - hasInputData && - '!text-[var(--text-primary)] dark:!text-[var(--text-primary)]' + !showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]' )} onClick={(e) => { e.stopPropagation() @@ -1509,7 +1507,7 @@ export function Terminal() { variant='ghost' className={clsx( 'px-[8px] py-[6px] text-[12px]', - showInput && '!text-[var(--text-primary)]' + showInput ? '!text-[var(--text-primary)]' : '!text-[var(--text-tertiary)]' )} onClick={(e) => { e.stopPropagation() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx index 3faa5498ac..43b846ea39 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx @@ -34,8 +34,8 @@ export const ActionBar = memo( const { collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, - collaborativeToggleBlockEnabled, - collaborativeToggleBlockHandles, + collaborativeBatchToggleBlockEnabled, + collaborativeBatchToggleBlockHandles, } = useCollaborativeWorkflow() const { activeWorkflowId } = useWorkflowRegistry() const blocks = useWorkflowStore((state) => state.blocks) @@ -121,7 +121,7 @@ export const ActionBar = memo( onClick={(e) => { e.stopPropagation() if (!disabled) { - collaborativeToggleBlockEnabled(blockId) + collaborativeBatchToggleBlockEnabled([blockId]) } }} className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]' @@ -192,7 +192,7 @@ export const ActionBar = memo( onClick={(e) => { e.stopPropagation() if (!disabled) { - collaborativeToggleBlockHandles(blockId) + collaborativeBatchToggleBlockHandles([blockId]) } }} className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/index.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-boundary-sync.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-boundary-sync.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-boundary-sync.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-boundary-sync.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-drag.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-drag.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-drag.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-drag.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-resize.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-float/use-float-resize.ts rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-resize.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts index 18747c43d3..65bd3d4e49 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts @@ -1,8 +1,16 @@ +export { + clearDragHighlights, + computeClampedPositionUpdates, + getClampedPositionForNode, + isInEditableElement, + selectNodesDeferred, + validateTriggerPaste, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers' +export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './float' export { useAutoLayout } from './use-auto-layout' export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions' export { useBlockVisual } from './use-block-visual' export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow' -export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './use-float' export { useNodeUtilities } from './use-node-utilities' export { usePreventZoom } from './use-prevent-zoom' export { useScrollManagement } from './use-scroll-management' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts index 7fd80df207..82795fc99e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts @@ -21,7 +21,7 @@ interface UseBlockVisualProps { /** * Provides visual state and interaction handlers for workflow blocks. - * Computes ring styling based on execution, focus, diff, and run path states. + * Computes ring styling based on execution, diff, deletion, and run path states. * In preview mode, all interactive and execution-related visual states are disabled. * * @param props - The hook properties @@ -46,8 +46,6 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId) const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId) - const currentBlockId = usePanelEditorStore((state) => state.currentBlockId) - const isFocused = isPreview ? false : currentBlockId === blockId const handleClick = useCallback(() => { if (!isPreview) { @@ -60,12 +58,11 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis getBlockRingStyles({ isActive, isPending: isPreview ? false : isPending, - isFocused, isDeletedBlock: isPreview ? false : isDeletedBlock, diffStatus: isPreview ? undefined : diffStatus, runPathStatus, }), - [isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus, isPreview] + [isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview] ) return { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts index 1b532c694f..e6f081b22f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts @@ -7,7 +7,6 @@ export type BlockRunPathStatus = 'success' | 'error' | undefined export interface BlockRingOptions { isActive: boolean isPending: boolean - isFocused: boolean isDeletedBlock: boolean diffStatus: BlockDiffStatus runPathStatus: BlockRunPathStatus @@ -15,18 +14,17 @@ export interface BlockRingOptions { /** * Derives visual ring visibility and class names for workflow blocks - * based on execution, focus, diff, deletion, and run-path states. + * based on execution, diff, deletion, and run-path states. */ export function getBlockRingStyles(options: BlockRingOptions): { hasRing: boolean ringClassName: string } { - const { isActive, isPending, isFocused, isDeletedBlock, diffStatus, runPathStatus } = options + const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus } = options const hasRing = isActive || isPending || - isFocused || diffStatus === 'new' || diffStatus === 'edited' || isDeletedBlock || @@ -39,34 +37,28 @@ export function getBlockRingStyles(options: BlockRingOptions): { !isActive && hasRing && 'ring-[1.75px]', // Pending state: warning ring !isActive && isPending && 'ring-[var(--warning)]', - // Focused (selected) state: brand ring - !isActive && !isPending && isFocused && 'ring-[var(--brand-secondary)]', - // Deleted state (highest priority after active/pending/focused) - !isActive && !isPending && !isFocused && isDeletedBlock && 'ring-[var(--text-error)]', + // Deleted state (highest priority after active/pending) + !isActive && !isPending && isDeletedBlock && 'ring-[var(--text-error)]', // Diff states !isActive && !isPending && - !isFocused && !isDeletedBlock && diffStatus === 'new' && 'ring-[var(--brand-tertiary)]', !isActive && !isPending && - !isFocused && !isDeletedBlock && diffStatus === 'edited' && 'ring-[var(--warning)]', // Run path states (lowest priority - only show if no other states active) !isActive && !isPending && - !isFocused && !isDeletedBlock && !diffStatus && runPathStatus === 'success' && 'ring-[var(--border-success)]', !isActive && !isPending && - !isFocused && !isDeletedBlock && !diffStatus && runPathStatus === 'error' && diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts new file mode 100644 index 0000000000..634aedb326 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -0,0 +1,141 @@ +import type { Node } from 'reactflow' +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' +import { TriggerUtils } from '@/lib/workflows/triggers/triggers' +import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' +import type { BlockState } from '@/stores/workflows/workflow/types' + +/** + * Checks if the currently focused element is an editable input. + * Returns true if the user is typing in an input, textarea, or contenteditable element. + */ +export function isInEditableElement(): boolean { + const activeElement = document.activeElement + return ( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement?.hasAttribute('contenteditable') === true + ) +} + +interface TriggerValidationResult { + isValid: boolean + message?: string +} + +/** + * Validates that pasting/duplicating trigger blocks won't violate constraints. + * Returns validation result with error message if invalid. + */ +export function validateTriggerPaste( + blocksToAdd: Array<{ type: string }>, + existingBlocks: Record, + action: 'paste' | 'duplicate' +): TriggerValidationResult { + for (const block of blocksToAdd) { + if (TriggerUtils.isAnyTriggerType(block.type)) { + const issue = TriggerUtils.getTriggerAdditionIssue(existingBlocks, block.type) + if (issue) { + const actionText = action === 'paste' ? 'paste' : 'duplicate' + const message = + issue.issue === 'legacy' + ? `Cannot ${actionText} trigger blocks when a legacy Start block exists.` + : `A workflow can only have one ${issue.triggerName} trigger block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : 'Cannot duplicate.'}` + return { isValid: false, message } + } + } + } + return { isValid: true } +} + +/** + * Clears drag highlight classes and resets cursor state. + * Used when drag operations end or are cancelled. + */ +export function clearDragHighlights(): void { + document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => { + el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') + }) + document.body.style.cursor = '' +} + +/** + * Selects nodes by their IDs after paste/duplicate operations. + * Defers selection to next animation frame to allow displayNodes to sync from store first. + * This is necessary because the component uses controlled state (nodes={displayNodes}) + * and newly added blocks need time to propagate through the store → derivedNodes → displayNodes cycle. + */ +export function selectNodesDeferred( + nodeIds: string[], + setDisplayNodes: (updater: (nodes: Node[]) => Node[]) => void +): void { + const idsSet = new Set(nodeIds) + requestAnimationFrame(() => { + setDisplayNodes((nodes) => + nodes.map((node) => ({ + ...node, + selected: idsSet.has(node.id), + })) + ) + }) +} + +interface BlockData { + height?: number + data?: { + parentId?: string + width?: number + height?: number + } +} + +/** + * Calculates the final position for a node, clamping it to parent container if needed. + * Returns the clamped position suitable for persistence. + */ +export function getClampedPositionForNode( + nodeId: string, + nodePosition: { x: number; y: number }, + blocks: Record, + allNodes: Node[] +): { x: number; y: number } { + const currentBlock = blocks[nodeId] + const currentParentId = currentBlock?.data?.parentId + + if (!currentParentId) { + return nodePosition + } + + const parentNode = allNodes.find((n) => n.id === currentParentId) + if (!parentNode) { + return nodePosition + } + + const containerDimensions = { + width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + const blockDimensions = { + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max( + currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT, + BLOCK_DIMENSIONS.MIN_HEIGHT + ), + } + + return clampPositionToContainer(nodePosition, containerDimensions, blockDimensions) +} + +/** + * Computes position updates for multiple nodes, clamping each to its parent container. + * Used for batch position updates after multi-node drag or selection drag. + */ +export function computeClampedPositionUpdates( + nodes: Node[], + blocks: Record, + allNodes: Node[] +): Array<{ id: string; position: { x: number; y: number } }> { + return nodes.map((node) => ({ + id: node.id, + position: getClampedPositionForNode(node.id, node.position, blocks, allNodes), + })) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index b906664058..8aa8cde388 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -11,6 +11,7 @@ import ReactFlow, { type NodeChange, type NodeTypes, ReactFlowProvider, + SelectionMode, useReactFlow, } from 'reactflow' import 'reactflow/dist/style.css' @@ -42,9 +43,15 @@ import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' import { + clearDragHighlights, + computeClampedPositionUpdates, + getClampedPositionForNode, + isInEditableElement, + selectNodesDeferred, useAutoLayout, useCurrentWorkflow, useNodeUtilities, + validateTriggerPaste, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu' import { @@ -180,11 +187,12 @@ const reactFlowStyles = [ const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const const reactFlowProOptions = { hideAttribution: true } as const -interface SelectedEdgeInfo { - id: string - parentLoopId?: string - contextId?: string -} +/** + * Map from edge contextId to edge id. + * Context IDs include parent loop info for edges inside loops. + * The actual edge ID is stored as the value for deletion operations. + */ +type SelectedEdgesMap = Map interface BlockData { id: string @@ -200,7 +208,7 @@ interface BlockData { const WorkflowContent = React.memo(() => { const [isCanvasReady, setIsCanvasReady] = useState(false) const [potentialParentId, setPotentialParentId] = useState(null) - const [selectedEdgeInfo, setSelectedEdgeInfo] = useState(null) + const [selectedEdges, setSelectedEdges] = useState(new Map()) const [isShiftPressed, setIsShiftPressed] = useState(false) const [isSelectionDragActive, setIsSelectionDragActive] = useState(false) const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false) @@ -280,7 +288,7 @@ const WorkflowContent = React.memo(() => { useStreamCleanup(copilotCleanup) - const { blocks, edges, isDiffMode, lastSaved } = currentWorkflow + const { blocks, edges, lastSaved } = currentWorkflow const isWorkflowReady = useMemo( () => @@ -343,6 +351,11 @@ 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) + /** Stores start positions for multi-node drag undo/redo recording. */ + const multiNodeDragStartRef = useRef>( + new Map() + ) + /** Re-applies diff markers when blocks change after socket rehydration. */ const blocksRef = useRef(blocks) useEffect(() => { @@ -431,12 +444,13 @@ const WorkflowContent = React.memo(() => { const { collaborativeAddEdge: addEdge, collaborativeRemoveEdge: removeEdge, + collaborativeBatchRemoveEdges, collaborativeBatchUpdatePositions, collaborativeUpdateParentId: updateParentId, collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, - collaborativeToggleBlockEnabled, - collaborativeToggleBlockHandles, + collaborativeBatchToggleBlockEnabled, + collaborativeBatchToggleBlockHandles, undo, redo, } = useCollaborativeWorkflow() @@ -636,22 +650,14 @@ const WorkflowContent = React.memo(() => { } = pasteData const pastedBlocksArray = Object.values(pastedBlocks) - for (const block of pastedBlocksArray) { - if (TriggerUtils.isAnyTriggerType(block.type)) { - const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type) - if (issue) { - const message = - issue.issue === 'legacy' - ? 'Cannot paste trigger blocks when a legacy Start block exists.' - : `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before pasting.` - addNotification({ - level: 'error', - message, - workflowId: activeWorkflowId || undefined, - }) - return - } - } + const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'paste') + if (!validation.isValid) { + addNotification({ + level: 'error', + message: validation.message!, + workflowId: activeWorkflowId || undefined, + }) + return } collaborativeBatchAddBlocks( @@ -661,6 +667,11 @@ const WorkflowContent = React.memo(() => { pastedParallels, pastedSubBlockValues ) + + selectNodesDeferred( + pastedBlocksArray.map((b) => b.id), + setDisplayNodes + ) }, [ hasClipboard, clipboard, @@ -687,22 +698,14 @@ const WorkflowContent = React.memo(() => { } = pasteData const pastedBlocksArray = Object.values(pastedBlocks) - for (const block of pastedBlocksArray) { - if (TriggerUtils.isAnyTriggerType(block.type)) { - const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type) - if (issue) { - const message = - issue.issue === 'legacy' - ? 'Cannot duplicate trigger blocks when a legacy Start block exists.' - : `A workflow can only have one ${issue.triggerName} trigger block. Cannot duplicate.` - addNotification({ - level: 'error', - message, - workflowId: activeWorkflowId || undefined, - }) - return - } - } + const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'duplicate') + if (!validation.isValid) { + addNotification({ + level: 'error', + message: validation.message!, + workflowId: activeWorkflowId || undefined, + }) + return } collaborativeBatchAddBlocks( @@ -712,6 +715,11 @@ const WorkflowContent = React.memo(() => { pastedParallels, pastedSubBlockValues ) + + selectNodesDeferred( + pastedBlocksArray.map((b) => b.id), + setDisplayNodes + ) }, [ contextMenuBlocks, copyBlocks, @@ -728,16 +736,14 @@ const WorkflowContent = React.memo(() => { }, [contextMenuBlocks, collaborativeBatchRemoveBlocks]) const handleContextToggleEnabled = useCallback(() => { - contextMenuBlocks.forEach((block) => { - collaborativeToggleBlockEnabled(block.id) - }) - }, [contextMenuBlocks, collaborativeToggleBlockEnabled]) + const blockIds = contextMenuBlocks.map((block) => block.id) + collaborativeBatchToggleBlockEnabled(blockIds) + }, [contextMenuBlocks, collaborativeBatchToggleBlockEnabled]) const handleContextToggleHandles = useCallback(() => { - contextMenuBlocks.forEach((block) => { - collaborativeToggleBlockHandles(block.id) - }) - }, [contextMenuBlocks, collaborativeToggleBlockHandles]) + const blockIds = contextMenuBlocks.map((block) => block.id) + collaborativeBatchToggleBlockHandles(blockIds) + }, [contextMenuBlocks, collaborativeBatchToggleBlockHandles]) const handleContextRemoveFromSubflow = useCallback(() => { contextMenuBlocks.forEach((block) => { @@ -788,13 +794,7 @@ const WorkflowContent = React.memo(() => { let cleanup: (() => void) | null = null const handleKeyDown = (event: KeyboardEvent) => { - const activeElement = document.activeElement - const isEditableElement = - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement?.hasAttribute('contenteditable') - - if (isEditableElement) { + if (isInEditableElement()) { event.stopPropagation() return } @@ -840,22 +840,14 @@ const WorkflowContent = React.memo(() => { const pasteData = preparePasteData(pasteOffset) if (pasteData) { const pastedBlocks = Object.values(pasteData.blocks) - for (const block of pastedBlocks) { - if (TriggerUtils.isAnyTriggerType(block.type)) { - const issue = TriggerUtils.getTriggerAdditionIssue(blocks, block.type) - if (issue) { - const message = - issue.issue === 'legacy' - ? 'Cannot paste trigger blocks when a legacy Start block exists.' - : `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before pasting.` - addNotification({ - level: 'error', - message, - workflowId: activeWorkflowId || undefined, - }) - return - } - } + const validation = validateTriggerPaste(pastedBlocks, blocks, 'paste') + if (!validation.isValid) { + addNotification({ + level: 'error', + message: validation.message!, + workflowId: activeWorkflowId || undefined, + }) + return } collaborativeBatchAddBlocks( @@ -865,6 +857,11 @@ const WorkflowContent = React.memo(() => { pasteData.parallels, pasteData.subBlockValues ) + + selectNodesDeferred( + pastedBlocks.map((b) => b.id), + setDisplayNodes + ) } } } @@ -1168,10 +1165,7 @@ const WorkflowContent = React.memo(() => { try { const containerInfo = isPointInLoopNode(position) - document - .querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over') - .forEach((el) => el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over')) - document.body.style.cursor = '' + clearDragHighlights() document.body.classList.remove('sim-drag-subflow') if (data.type === 'loop' || data.type === 'parallel') { @@ -1611,11 +1605,7 @@ const WorkflowContent = React.memo(() => { const containerInfo = isPointInLoopNode(position) // Clear any previous highlighting - document - .querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over') - .forEach((el) => { - el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - }) + clearDragHighlights() // Highlight container if hovering over it and not dragging a subflow // Subflow drag is marked by body class flag set by toolbar @@ -1815,7 +1805,7 @@ const WorkflowContent = React.memo(() => { const nodeArray: Node[] = [] // Add block nodes - Object.entries(blocks).forEach(([blockId, block]) => { + Object.entries(blocks).forEach(([, block]) => { if (!block || !block.type || !block.name) { return } @@ -1892,8 +1882,11 @@ const WorkflowContent = React.memo(() => { }, // Include dynamic dimensions for container resizing calculations (must match rendered size) // Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions + // Use estimated dimensions for blocks without measured height to ensure selection bounds are correct width: BLOCK_DIMENSIONS.FIXED_WIDTH, - height: Math.max(block.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT), + height: block.height + ? Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT) + : estimateBlockDimensions(block.type).height, }) }) @@ -1945,7 +1938,14 @@ const WorkflowContent = React.memo(() => { }, [isShiftPressed]) useEffect(() => { - setDisplayNodes(derivedNodes) + // Preserve selection state when syncing from derivedNodes + setDisplayNodes((currentNodes) => { + const selectedIds = new Set(currentNodes.filter((n) => n.selected).map((n) => n.id)) + return derivedNodes.map((node) => ({ + ...node, + selected: selectedIds.has(node.id), + })) + }) }, [derivedNodes]) /** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */ @@ -2259,12 +2259,8 @@ const WorkflowContent = React.memo(() => { if (isStarterBlock) { // If it's a starter block, remove any highlighting and don't allow it to be dragged into containers if (potentialParentId) { - const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`) - if (prevElement) { - prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - } + clearDragHighlights() setPotentialParentId(null) - document.body.style.cursor = '' } return // Exit early - don't process any container intersections for starter blocks } @@ -2276,12 +2272,8 @@ const WorkflowContent = React.memo(() => { if (node.type === 'subflowNode') { // Clear any highlighting for subflow nodes if (potentialParentId) { - const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`) - if (prevElement) { - prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - } + clearDragHighlights() setPotentialParentId(null) - document.body.style.cursor = '' } return // Exit early - subflows cannot be placed inside other subflows } @@ -2382,12 +2374,8 @@ const WorkflowContent = React.memo(() => { } else { // Remove highlighting if no longer over a container if (potentialParentId) { - const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`) - if (prevElement) { - prevElement.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - } + clearDragHighlights() setPotentialParentId(null) - document.body.style.cursor = '' } } }, @@ -2414,49 +2402,48 @@ const WorkflowContent = React.memo(() => { y: node.position.y, parentId: currentParentId, }) + + // Capture all selected nodes' positions for multi-node undo/redo + const allNodes = getNodes() + const selectedNodes = allNodes.filter((n) => n.selected) + multiNodeDragStartRef.current.clear() + selectedNodes.forEach((n) => { + multiNodeDragStartRef.current.set(n.id, { + x: n.position.x, + y: n.position.y, + parentId: blocks[n.id]?.data?.parentId, + }) + }) }, - [blocks, setDragStartPosition] + [blocks, setDragStartPosition, getNodes] ) /** Handles node drag stop to establish parent-child relationships. */ const onNodeDragStop = useCallback( (_event: React.MouseEvent, node: any) => { - // Clear UI effects - document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => { - el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') - }) - document.body.style.cursor = '' + clearDragHighlights() - // Get the block's current parent (if any) - const currentBlock = blocks[node.id] - const currentParentId = currentBlock?.data?.parentId + // Get all selected nodes to update their positions too + const allNodes = getNodes() + const selectedNodes = allNodes.filter((n) => n.selected) - // Calculate position - clamp if inside a container - let finalPosition = node.position - if (currentParentId) { - // Block is inside a container - clamp position to keep it fully inside - const parentNode = getNodes().find((n) => n.id === currentParentId) - if (parentNode) { - const containerDimensions = { - width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - } - const blockDimensions = { - width: BLOCK_DIMENSIONS.FIXED_WIDTH, - height: Math.max( - currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT, - BLOCK_DIMENSIONS.MIN_HEIGHT - ), - } + // If multiple nodes are selected, update all their positions + if (selectedNodes.length > 1) { + const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes) + collaborativeBatchUpdatePositions(positionUpdates, { + previousPositions: multiNodeDragStartRef.current, + }) - finalPosition = clampPositionToContainer( - node.position, - containerDimensions, - blockDimensions - ) - } + // Clear drag start state + setDragStartPosition(null) + setPotentialParentId(null) + multiNodeDragStartRef.current.clear() + return } + // Single node drag - original logic + const finalPosition = getClampedPositionForNode(node.id, node.position, blocks, allNodes) + updateBlockPosition(node.id, finalPosition) // Record single move entry on drag end to avoid micro-moves @@ -2589,6 +2576,7 @@ const WorkflowContent = React.memo(() => { setDragStartPosition, addNotification, activeWorkflowId, + collaborativeBatchUpdatePositions, ] ) @@ -2608,47 +2596,41 @@ const WorkflowContent = React.memo(() => { requestAnimationFrame(() => setIsSelectionDragActive(false)) if (nodes.length === 0) return - const positionUpdates = nodes.map((node) => { - const currentBlock = blocks[node.id] - const currentParentId = currentBlock?.data?.parentId - let finalPosition = node.position - - if (currentParentId) { - const parentNode = getNodes().find((n) => n.id === currentParentId) - if (parentNode) { - const containerDimensions = { - width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - } - const blockDimensions = { - width: BLOCK_DIMENSIONS.FIXED_WIDTH, - height: Math.max( - currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT, - BLOCK_DIMENSIONS.MIN_HEIGHT - ), - } - finalPosition = clampPositionToContainer( - node.position, - containerDimensions, - blockDimensions - ) - } - } - - return { id: node.id, position: finalPosition } + const allNodes = getNodes() + const positionUpdates = computeClampedPositionUpdates(nodes, blocks, allNodes) + collaborativeBatchUpdatePositions(positionUpdates, { + previousPositions: multiNodeDragStartRef.current, }) - - collaborativeBatchUpdatePositions(positionUpdates) + multiNodeDragStartRef.current.clear() }, [blocks, getNodes, collaborativeBatchUpdatePositions] ) const onPaneClick = useCallback(() => { - setSelectedEdgeInfo(null) + setSelectedEdges(new Map()) usePanelEditorStore.getState().clearCurrentBlock() }, []) - /** Handles edge selection with container context tracking. */ + /** + * Handles node click to select the node in ReactFlow. + * This ensures clicking anywhere on a block (not just the drag handle) + * selects it for delete/backspace and multi-select operations. + */ + 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, + })) + ) + }, + [setNodes] + ) + + /** Handles edge selection with container context tracking and Shift-click multi-selection. */ const onEdgeClick = useCallback( (event: React.MouseEvent, edge: any) => { event.stopPropagation() // Prevent bubbling @@ -2664,11 +2646,21 @@ const WorkflowContent = React.memo(() => { // Create a unique identifier that combines edge ID and parent context const contextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}` - setSelectedEdgeInfo({ - id: edge.id, - parentLoopId, - contextId, - }) + if (event.shiftKey) { + // Shift-click: toggle edge in selection + setSelectedEdges((prev) => { + const next = new Map(prev) + if (next.has(contextId)) { + next.delete(contextId) + } else { + next.set(contextId, edge.id) + } + return next + }) + } else { + // Normal click: replace selection with this edge + setSelectedEdges(new Map([[contextId, edge.id]])) + } }, [getNodes] ) @@ -2677,7 +2669,16 @@ const WorkflowContent = React.memo(() => { const handleEdgeDelete = useCallback( (edgeId: string) => { removeEdge(edgeId) - setSelectedEdgeInfo((current) => (current?.id === edgeId ? null : current)) + // Remove this edge from selection (find by edge ID value) + setSelectedEdges((prev) => { + const next = new Map(prev) + for (const [contextId, id] of next) { + if (id === edgeId) { + next.delete(contextId) + } + } + return next + }) }, [removeEdge] ) @@ -2697,7 +2698,7 @@ const WorkflowContent = React.memo(() => { ...edge, data: { ...edge.data, - isSelected: selectedEdgeInfo?.contextId === edgeContextId, + isSelected: selectedEdges.has(edgeContextId), isInsideLoop: Boolean(parentLoopId), parentLoopId, sourceHandle: edge.sourceHandle, @@ -2705,7 +2706,7 @@ const WorkflowContent = React.memo(() => { }, } }) - }, [edgesForDisplay, displayNodes, selectedEdgeInfo?.contextId, handleEdgeDelete]) + }, [edgesForDisplay, displayNodes, selectedEdges, handleEdgeDelete]) /** Handles Delete/Backspace to remove selected edges or blocks. */ useEffect(() => { @@ -2715,20 +2716,16 @@ const WorkflowContent = React.memo(() => { } // Ignore when typing/navigating inside editable inputs or editors - const activeElement = document.activeElement - const isEditableElement = - activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement || - activeElement?.hasAttribute('contenteditable') - - if (isEditableElement) { + if (isInEditableElement()) { return } // Handle edge deletion first (edges take priority if selected) - if (selectedEdgeInfo) { - removeEdge(selectedEdgeInfo.id) - setSelectedEdgeInfo(null) + if (selectedEdges.size > 0) { + // Get all selected edge IDs and batch delete them + const edgeIds = Array.from(selectedEdges.values()) + collaborativeBatchRemoveEdges(edgeIds) + setSelectedEdges(new Map()) return } @@ -2750,8 +2747,8 @@ const WorkflowContent = React.memo(() => { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [ - selectedEdgeInfo, - removeEdge, + selectedEdges, + collaborativeBatchRemoveEdges, getNodes, collaborativeBatchRemoveBlocks, effectivePermissions.canEdit, @@ -2808,6 +2805,7 @@ const WorkflowContent = React.memo(() => { connectionLineType={ConnectionLineType.SmoothStep} onPaneClick={onPaneClick} onEdgeClick={onEdgeClick} + onNodeClick={handleNodeClick} onPaneContextMenu={handlePaneContextMenu} onNodeContextMenu={handleNodeContextMenu} onSelectionContextMenu={handleSelectionContextMenu} @@ -2815,10 +2813,11 @@ const WorkflowContent = React.memo(() => { onPointerLeave={handleCanvasPointerLeave} elementsSelectable={true} selectionOnDrag={isShiftPressed || isSelectionDragActive} + selectionMode={SelectionMode.Partial} panOnDrag={isShiftPressed || isSelectionDragActive ? false : [0, 1]} onSelectionStart={onSelectionStart} onSelectionEnd={onSelectionEnd} - multiSelectionKeyCode={['Meta', 'Control']} + multiSelectionKeyCode={['Meta', 'Control', 'Shift']} nodesConnectable={effectivePermissions.canEdit} nodesDraggable={effectivePermissions.canEdit} draggable={false} diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx index 76ef355ee3..1e6f7bf424 100644 --- a/apps/sim/components/emails/components/email-footer.tsx +++ b/apps/sim/components/emails/components/email-footer.tsx @@ -112,7 +112,7 @@ export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }: {brand.name} - {isHosted && <>, 80 Langton St, San Francisco, CA 94133, USA} + {isHosted && <>, 80 Langton St, San Francisco, CA 94103, USA}   diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 4df0e00f40..9825ccea06 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -20,8 +20,6 @@ import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/wo const logger = createLogger('CollaborativeWorkflow') -const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath'] - export function useCollaborativeWorkflow() { const undoRedo = useUndoRedo() const isUndoRedoInProgress = useRef(false) @@ -33,7 +31,7 @@ export function useCollaborativeWorkflow() { const { blockId, before, after } = e.detail || {} if (!blockId || !before || !after) return if (isUndoRedoInProgress.current) return - undoRedo.recordMove(blockId, before, after) + undoRedo.recordBatchMoveBlocks([{ blockId, before, after }]) } const parentUpdateHandler = (e: any) => { @@ -290,6 +288,34 @@ export function useCollaborativeWorkflow() { break } } + } else if (target === 'edges') { + switch (operation) { + case 'batch-remove-edges': { + const { ids } = payload + if (Array.isArray(ids)) { + ids.forEach((id: string) => { + workflowStore.removeEdge(id) + }) + + const updatedBlocks = useWorkflowStore.getState().blocks + const updatedEdges = useWorkflowStore.getState().edges + const graph = { + blocksById: updatedBlocks, + edgesById: Object.fromEntries(updatedEdges.map((e) => [e.id, e])), + } + + const undoRedoStore = useUndoRedoStore.getState() + const stackKeys = Object.keys(undoRedoStore.stacks) + stackKeys.forEach((key) => { + const [wfId, uId] = key.split(':') + if (wfId === activeWorkflowId) { + undoRedoStore.pruneInvalidEntries(wfId, uId, graph) + } + }) + } + break + } + } } else if (target === 'subflow') { switch (operation) { case 'update': @@ -722,7 +748,12 @@ export function useCollaborativeWorkflow() { ) const collaborativeBatchUpdatePositions = useCallback( - (updates: Array<{ id: string; position: Position }>) => { + ( + updates: Array<{ id: string; position: Position }>, + options?: { + previousPositions?: Map + } + ) => { if (!isInActiveRoom()) { logger.debug('Skipping batch position update - not in active workflow') return @@ -746,8 +777,31 @@ export function useCollaborativeWorkflow() { updates.forEach(({ id, position }) => { workflowStore.updateBlockPosition(id, position) }) + + if (options?.previousPositions && options.previousPositions.size > 0) { + const moves = updates + .filter((u) => options.previousPositions!.has(u.id)) + .map((u) => { + const prev = options.previousPositions!.get(u.id)! + const block = workflowStore.blocks[u.id] + return { + blockId: u.id, + before: prev, + after: { + x: u.position.x, + y: u.position.y, + parentId: block?.data?.parentId, + }, + } + }) + .filter((m) => m.before.x !== m.after.x || m.before.y !== m.after.y) + + if (moves.length > 0) { + undoRedo.recordBatchMoveBlocks(moves) + } + } }, - [addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore] + [addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo] ) const collaborativeUpdateBlockName = useCallback( @@ -822,13 +876,43 @@ export function useCollaborativeWorkflow() { [executeQueuedOperation, workflowStore, addToQueue, activeWorkflowId, session?.user?.id] ) - const collaborativeToggleBlockEnabled = useCallback( - (id: string) => { - executeQueuedOperation('toggle-enabled', 'block', { id }, () => + const collaborativeBatchToggleBlockEnabled = useCallback( + (ids: string[]) => { + if (ids.length === 0) return + + const previousStates: Record = {} + const validIds: string[] = [] + + for (const id of ids) { + const block = workflowStore.blocks[id] + if (block) { + previousStates[id] = block.enabled + validIds.push(id) + } + } + + if (validIds.length === 0) return + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: 'batch-toggle-enabled', + target: 'blocks', + payload: { blockIds: validIds, previousStates }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + for (const id of validIds) { workflowStore.toggleBlockEnabled(id) - ) + } + + undoRedo.recordBatchToggleEnabled(validIds, previousStates) }, - [executeQueuedOperation, workflowStore] + [addToQueue, activeWorkflowId, session?.user?.id, workflowStore, undoRedo] ) const collaborativeUpdateParentId = useCallback( @@ -888,27 +972,48 @@ export function useCollaborativeWorkflow() { [executeQueuedOperation, workflowStore] ) - const collaborativeToggleBlockHandles = useCallback( - (id: string) => { - const currentBlock = workflowStore.blocks[id] - if (!currentBlock) return + const collaborativeBatchToggleBlockHandles = useCallback( + (ids: string[]) => { + if (ids.length === 0) return - const newHorizontalHandles = !currentBlock.horizontalHandles + const previousStates: Record = {} + const validIds: string[] = [] - executeQueuedOperation( - 'toggle-handles', - 'block', - { id, horizontalHandles: newHorizontalHandles }, - () => workflowStore.toggleBlockHandles(id) - ) + for (const id of ids) { + const block = workflowStore.blocks[id] + if (block) { + previousStates[id] = block.horizontalHandles ?? false + validIds.push(id) + } + } + + if (validIds.length === 0) return + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: 'batch-toggle-handles', + target: 'blocks', + payload: { blockIds: validIds, previousStates }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + for (const id of validIds) { + workflowStore.toggleBlockHandles(id) + } + + undoRedo.recordBatchToggleHandles(validIds, previousStates) }, - [executeQueuedOperation, workflowStore] + [addToQueue, activeWorkflowId, session?.user?.id, workflowStore, undoRedo] ) const collaborativeAddEdge = useCallback( (edge: Edge) => { executeQueuedOperation('add', 'edge', edge, () => workflowStore.addEdge(edge)) - // Only record edge addition if it's not part of a parent update operation if (!skipEdgeRecording.current) { undoRedo.recordAddEdge(edge.id) } @@ -920,13 +1025,11 @@ export function useCollaborativeWorkflow() { (edgeId: string) => { const edge = workflowStore.edges.find((e) => e.id === edgeId) - // Skip if edge doesn't exist (already removed during cascade deletion) if (!edge) { logger.debug('Edge already removed, skipping operation', { edgeId }) return } - // Check if the edge's source and target blocks still exist const sourceExists = workflowStore.blocks[edge.source] const targetExists = workflowStore.blocks[edge.target] @@ -939,9 +1042,8 @@ export function useCollaborativeWorkflow() { return } - // Only record edge removal if it's not part of a parent update operation if (!skipEdgeRecording.current) { - undoRedo.recordRemoveEdge(edgeId, edge) + undoRedo.recordBatchRemoveEdges([edge]) } executeQueuedOperation('remove', 'edge', { id: edgeId }, () => @@ -951,11 +1053,64 @@ export function useCollaborativeWorkflow() { [executeQueuedOperation, workflowStore, undoRedo] ) + const collaborativeBatchRemoveEdges = useCallback( + (edgeIds: string[], options?: { skipUndoRedo?: boolean }) => { + if (!isInActiveRoom()) { + logger.debug('Skipping batch remove edges - not in active workflow') + return false + } + + if (edgeIds.length === 0) return false + + const edgeSnapshots: Edge[] = [] + const validEdgeIds: string[] = [] + + for (const edgeId of edgeIds) { + const edge = workflowStore.edges.find((e) => e.id === edgeId) + if (edge) { + const sourceExists = workflowStore.blocks[edge.source] + const targetExists = workflowStore.blocks[edge.target] + if (sourceExists && targetExists) { + edgeSnapshots.push(edge) + validEdgeIds.push(edgeId) + } + } + } + + if (validEdgeIds.length === 0) { + logger.debug('No valid edges to remove') + return false + } + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: 'batch-remove-edges', + target: 'edges', + payload: { ids: validEdgeIds }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + validEdgeIds.forEach((id) => workflowStore.removeEdge(id)) + + if (!options?.skipUndoRedo && edgeSnapshots.length > 0) { + undoRedo.recordBatchRemoveEdges(edgeSnapshots) + } + + logger.info('Batch removed edges', { count: validEdgeIds.length }) + return true + }, + [isInActiveRoom, workflowStore, addToQueue, activeWorkflowId, session, undoRedo] + ) + const collaborativeSetSubblockValue = useCallback( (blockId: string, subblockId: string, value: any, options?: { _visited?: Set }) => { if (isApplyingRemoteChange.current) return - // Skip socket operations when viewing baseline diff if (isBaselineDiffView) { logger.debug('Skipping collaborative subblock update while viewing baseline diff') return @@ -971,13 +1126,10 @@ export function useCollaborativeWorkflow() { return } - // Generate operation ID for queue tracking const operationId = crypto.randomUUID() - // Get fresh activeWorkflowId from store to avoid stale closure const currentActiveWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - // Add to queue for retry mechanism addToQueue({ id: operationId, operation: { @@ -989,10 +1141,8 @@ export function useCollaborativeWorkflow() { userId: session?.user?.id || 'unknown', }) - // Apply locally first (immediate UI feedback) subBlockStore.setValue(blockId, subblockId, value) - // Declarative clearing: clear sub-blocks that depend on this subblockId try { const visited = options?._visited || new Set() if (visited.has(subblockId)) return @@ -1004,9 +1154,7 @@ export function useCollaborativeWorkflow() { (sb: any) => Array.isArray(sb.dependsOn) && sb.dependsOn.includes(subblockId) ) for (const dep of dependents) { - // Skip clearing if the dependent is the same field if (!dep?.id || dep.id === subblockId) continue - // Cascade using the same collaborative path so it emits and further cascades collaborativeSetSubblockValue(blockId, dep.id, '', { _visited: visited }) } } @@ -1512,15 +1660,16 @@ export function useCollaborativeWorkflow() { // Collaborative operations collaborativeBatchUpdatePositions, collaborativeUpdateBlockName, - collaborativeToggleBlockEnabled, + collaborativeBatchToggleBlockEnabled, collaborativeUpdateParentId, collaborativeToggleBlockAdvancedMode, collaborativeToggleBlockTriggerMode, - collaborativeToggleBlockHandles, + collaborativeBatchToggleBlockHandles, collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, collaborativeAddEdge, collaborativeRemoveEdge, + collaborativeBatchRemoveEdges, collaborativeSetSubblockValue, collaborativeSetTagSelection, diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 33457cf390..aa40c9c70f 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -6,11 +6,13 @@ import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-o import { useOperationQueue } from '@/stores/operation-queue/store' import { type BatchAddBlocksOperation, + type BatchMoveBlocksOperation, type BatchRemoveBlocksOperation, + type BatchRemoveEdgesOperation, + type BatchToggleEnabledOperation, + type BatchToggleHandlesOperation, createOperationEntry, - type MoveBlockOperation, type Operation, - type RemoveEdgeOperation, runWithUndoRedoRecordingSuspended, type UpdateParentOperation, useUndoRedoStore, @@ -128,6 +130,8 @@ export function useUndoRedo() { (edgeId: string) => { if (!activeWorkflowId) return + const edgeSnapshot = workflowStore.edges.find((e) => e.id === edgeId) + const operation: Operation = { id: crypto.randomUUID(), type: 'add-edge', @@ -137,15 +141,15 @@ export function useUndoRedo() { data: { edgeId }, } - const inverse: RemoveEdgeOperation = { + // Inverse is batch-remove-edges with a single edge + const inverse: BatchRemoveEdgesOperation = { id: crypto.randomUUID(), - type: 'remove-edge', + type: 'batch-remove-edges', timestamp: Date.now(), workflowId: activeWorkflowId, userId, data: { - edgeId, - edgeSnapshot: workflowStore.edges.find((e) => e.id === edgeId) || null, + edgeSnapshots: edgeSnapshot ? [edgeSnapshot] : [], }, } @@ -157,77 +161,83 @@ export function useUndoRedo() { [activeWorkflowId, userId, workflowStore, undoRedoStore] ) - const recordRemoveEdge = useCallback( - (edgeId: string, edgeSnapshot: Edge) => { - if (!activeWorkflowId) return + const recordBatchRemoveEdges = useCallback( + (edgeSnapshots: Edge[]) => { + if (!activeWorkflowId || edgeSnapshots.length === 0) return - const operation: RemoveEdgeOperation = { + const operation: BatchRemoveEdgesOperation = { id: crypto.randomUUID(), - type: 'remove-edge', + type: 'batch-remove-edges', timestamp: Date.now(), workflowId: activeWorkflowId, userId, data: { - edgeId, - edgeSnapshot, + edgeSnapshots, }, } - const inverse: Operation = { + // Inverse is batch-add-edges (using the snapshots to restore) + const inverse: BatchRemoveEdgesOperation = { id: crypto.randomUUID(), - type: 'add-edge', + type: 'batch-remove-edges', timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { edgeId }, + data: { + edgeSnapshots, + }, } const entry = createOperationEntry(operation, inverse) undoRedoStore.push(activeWorkflowId, userId, entry) - logger.debug('Recorded remove edge', { edgeId, workflowId: activeWorkflowId }) + logger.debug('Recorded batch remove edges', { + edgeCount: edgeSnapshots.length, + workflowId: activeWorkflowId, + }) }, [activeWorkflowId, userId, undoRedoStore] ) - const recordMove = useCallback( + const recordBatchMoveBlocks = useCallback( ( - blockId: string, - before: { x: number; y: number; parentId?: string }, - after: { x: number; y: number; parentId?: string } + moves: Array<{ + blockId: string + before: { x: number; y: number; parentId?: string } + after: { x: number; y: number; parentId?: string } + }> ) => { - if (!activeWorkflowId) return + if (!activeWorkflowId || moves.length === 0) return - const operation: MoveBlockOperation = { + const operation: BatchMoveBlocksOperation = { id: crypto.randomUUID(), - type: 'move-block', + type: 'batch-move-blocks', timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { - blockId, - before, - after, - }, + data: { moves }, } - const inverse: MoveBlockOperation = { + // Inverse swaps before/after for each move + const inverse: BatchMoveBlocksOperation = { id: crypto.randomUUID(), - type: 'move-block', + type: 'batch-move-blocks', timestamp: Date.now(), workflowId: activeWorkflowId, userId, data: { - blockId, - before: after, - after: before, + moves: moves.map((m) => ({ + blockId: m.blockId, + before: m.after, + after: m.before, + })), }, } const entry = createOperationEntry(operation, inverse) undoRedoStore.push(activeWorkflowId, userId, entry) - logger.debug('Recorded move', { blockId, from: before, to: after }) + logger.debug('Recorded batch move', { blockCount: moves.length }) }, [activeWorkflowId, userId, undoRedoStore] ) @@ -288,6 +298,66 @@ export function useUndoRedo() { [activeWorkflowId, userId, undoRedoStore] ) + const recordBatchToggleEnabled = useCallback( + (blockIds: string[], previousStates: Record) => { + if (!activeWorkflowId || blockIds.length === 0) return + + const operation: BatchToggleEnabledOperation = { + id: crypto.randomUUID(), + type: 'batch-toggle-enabled', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { blockIds, previousStates }, + } + + const inverse: BatchToggleEnabledOperation = { + id: crypto.randomUUID(), + type: 'batch-toggle-enabled', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { blockIds, previousStates }, + } + + const entry = createOperationEntry(operation, inverse) + undoRedoStore.push(activeWorkflowId, userId, entry) + + logger.debug('Recorded batch toggle enabled', { blockIds, previousStates }) + }, + [activeWorkflowId, userId, undoRedoStore] + ) + + const recordBatchToggleHandles = useCallback( + (blockIds: string[], previousStates: Record) => { + if (!activeWorkflowId || blockIds.length === 0) return + + const operation: BatchToggleHandlesOperation = { + id: crypto.randomUUID(), + type: 'batch-toggle-handles', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { blockIds, previousStates }, + } + + const inverse: BatchToggleHandlesOperation = { + id: crypto.randomUUID(), + type: 'batch-toggle-handles', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { blockIds, previousStates }, + } + + const entry = createOperationEntry(operation, inverse) + undoRedoStore.push(activeWorkflowId, userId, entry) + + logger.debug('Recorded batch toggle handles', { blockIds, previousStates }) + }, + [activeWorkflowId, userId, undoRedoStore] + ) + const undo = useCallback(async () => { if (!activeWorkflowId) return @@ -422,93 +492,82 @@ export function useUndoRedo() { } break } - case 'remove-edge': { - const removeEdgeInverse = entry.inverse as RemoveEdgeOperation - const { edgeId } = removeEdgeInverse.data - if (workflowStore.edges.find((e) => e.id === edgeId)) { - addToQueue({ - id: opId, - operation: { - operation: 'remove', - target: 'edge', - payload: { - id: edgeId, - isUndo: true, - originalOpId: entry.id, + case 'batch-remove-edges': { + const batchRemoveInverse = entry.inverse as BatchRemoveEdgesOperation + const { edgeSnapshots } = batchRemoveInverse.data + + if (entry.operation.type === 'add-edge') { + // Undo add-edge: remove the edges that were added + const edgesToRemove = edgeSnapshots + .filter((e) => workflowStore.edges.find((edge) => edge.id === e.id)) + .map((e) => e.id) + + if (edgesToRemove.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: 'batch-remove-edges', + target: 'edges', + payload: { ids: edgesToRemove }, }, - }, - workflowId: activeWorkflowId, - userId, - }) - workflowStore.removeEdge(edgeId) + workflowId: activeWorkflowId, + userId, + }) + edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) + } + logger.debug('Undid add-edge', { edgeCount: edgesToRemove.length }) } else { - logger.debug('Undo remove-edge skipped; edge missing', { - edgeId, - }) - } - break - } - case 'add-edge': { - const originalOp = entry.operation as RemoveEdgeOperation - const { edgeSnapshot } = originalOp.data - // Skip if snapshot missing or already exists - if (!edgeSnapshot || workflowStore.edges.find((e) => e.id === edgeSnapshot.id)) { - logger.debug('Undo add-edge skipped', { - hasSnapshot: Boolean(edgeSnapshot), - }) - break + // Undo batch-remove-edges: add edges back + const edgesToAdd = edgeSnapshots.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + + if (edgesToAdd.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } + logger.debug('Undid batch-remove-edges', { edgeCount: edgesToAdd.length }) } - addToQueue({ - id: opId, - operation: { - operation: 'add', - target: 'edge', - payload: { ...edgeSnapshot, isUndo: true, originalOpId: entry.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - workflowStore.addEdge(edgeSnapshot) break } - case 'move-block': { - const moveOp = entry.inverse as MoveBlockOperation + case 'batch-move-blocks': { + const batchMoveOp = entry.inverse as BatchMoveBlocksOperation const currentBlocks = useWorkflowStore.getState().blocks - if (currentBlocks[moveOp.data.blockId]) { - // Apply the inverse's target as the undo result (inverse.after) + const positionUpdates: Array<{ id: string; position: { x: number; y: number } }> = [] + + for (const move of batchMoveOp.data.moves) { + if (currentBlocks[move.blockId]) { + positionUpdates.push({ + id: move.blockId, + position: { x: move.after.x, y: move.after.y }, + }) + workflowStore.updateBlockPosition(move.blockId, { + x: move.after.x, + y: move.after.y, + }) + } + } + + if (positionUpdates.length > 0) { addToQueue({ id: opId, operation: { - operation: 'update-position', - target: 'block', - payload: { - id: moveOp.data.blockId, - position: { x: moveOp.data.after.x, y: moveOp.data.after.y }, - parentId: moveOp.data.after.parentId, - commit: true, - isUndo: true, - originalOpId: entry.id, - }, + operation: 'batch-update-positions', + target: 'blocks', + payload: { updates: positionUpdates }, }, workflowId: activeWorkflowId, userId, }) - // Use the store from the hook context for React re-renders - workflowStore.updateBlockPosition(moveOp.data.blockId, { - x: moveOp.data.after.x, - y: moveOp.data.after.y, - }) - if (moveOp.data.after.parentId !== moveOp.data.before.parentId) { - workflowStore.updateParentId( - moveOp.data.blockId, - moveOp.data.after.parentId || '', - 'parent' - ) - } - } else { - logger.debug('Undo move-block skipped; block missing', { - blockId: moveOp.data.blockId, - }) } break } @@ -520,21 +579,22 @@ export function useUndoRedo() { if (workflowStore.blocks[blockId]) { // If we're moving back INTO a subflow, restore edges first if (newParentId && affectedEdges && affectedEdges.length > 0) { - affectedEdges.forEach((edge) => { - if (!workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.addEdge(edge) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'add', - target: 'edge', - payload: { ...edge, isUndo: true }, - }, - workflowId: activeWorkflowId, - userId, - }) - } - }) + const edgesToAdd = affectedEdges.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + if (edgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } } // Send position update to server @@ -602,8 +662,65 @@ export function useUndoRedo() { } break } + case 'batch-toggle-enabled': { + const toggleOp = entry.inverse as BatchToggleEnabledOperation + const { blockIds, previousStates } = toggleOp.data + + const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (validBlockIds.length === 0) { + logger.debug('Undo batch-toggle-enabled skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: 'batch-toggle-enabled', + target: 'blocks', + payload: { blockIds: validBlockIds, previousStates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + validBlockIds.forEach((blockId) => { + const targetState = previousStates[blockId] + if (workflowStore.blocks[blockId].enabled !== targetState) { + workflowStore.toggleBlockEnabled(blockId) + } + }) + break + } + case 'batch-toggle-handles': { + const toggleOp = entry.inverse as BatchToggleHandlesOperation + const { blockIds, previousStates } = toggleOp.data + + const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (validBlockIds.length === 0) { + logger.debug('Undo batch-toggle-handles skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: 'batch-toggle-handles', + target: 'blocks', + payload: { blockIds: validBlockIds, previousStates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + validBlockIds.forEach((blockId) => { + const targetState = previousStates[blockId] + if (workflowStore.blocks[blockId].horizontalHandles !== targetState) { + workflowStore.toggleBlockHandles(blockId) + } + }) + break + } case 'apply-diff': { - // Undo apply-diff means clearing the diff and restoring baseline const applyDiffInverse = entry.inverse as any const { baselineSnapshot } = applyDiffInverse.data @@ -667,7 +784,6 @@ export function useUndoRedo() { const acceptDiffInverse = entry.inverse as any const acceptDiffOp = entry.operation as any const { beforeAccept, diffAnalysis } = acceptDiffInverse.data - const { baselineSnapshot } = acceptDiffOp.data const { useWorkflowDiffStore } = await import('@/stores/workflow-diff/store') const diffStore = useWorkflowDiffStore.getState() @@ -725,7 +841,6 @@ export function useUndoRedo() { case 'reject-diff': { // Undo reject-diff means restoring diff view with markers const rejectDiffInverse = entry.inverse as any - const rejectDiffOp = entry.operation as any const { beforeReject, diffAnalysis, baselineSnapshot } = rejectDiffInverse.data const { useWorkflowDiffStore } = await import('@/stores/workflow-diff/store') const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') @@ -886,84 +1001,83 @@ export function useUndoRedo() { break } case 'add-edge': { - // Use snapshot from inverse - const inv = entry.inverse as RemoveEdgeOperation - const snap = inv.data.edgeSnapshot - if (!snap || workflowStore.edges.find((e) => e.id === snap.id)) { - logger.debug('Redo add-edge skipped', { hasSnapshot: Boolean(snap) }) - break + const inv = entry.inverse as BatchRemoveEdgesOperation + const edgeSnapshots = inv.data.edgeSnapshots + + const edgesToAdd = edgeSnapshots.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + + if (edgesToAdd.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) } - addToQueue({ - id: opId, - operation: { - operation: 'add', - target: 'edge', - payload: { ...snap, isRedo: true, originalOpId: entry.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - workflowStore.addEdge(snap) break } - case 'remove-edge': { - const { edgeId } = entry.operation.data - if (workflowStore.edges.find((e) => e.id === edgeId)) { + case 'batch-remove-edges': { + // Redo batch-remove-edges: remove all edges again + const batchRemoveOp = entry.operation as BatchRemoveEdgesOperation + const { edgeSnapshots } = batchRemoveOp.data + + const edgesToRemove = edgeSnapshots + .filter((e) => workflowStore.edges.find((edge) => edge.id === e.id)) + .map((e) => e.id) + + if (edgesToRemove.length > 0) { addToQueue({ id: opId, operation: { - operation: 'remove', - target: 'edge', - payload: { id: edgeId, isRedo: true, originalOpId: entry.id }, + operation: 'batch-remove-edges', + target: 'edges', + payload: { ids: edgesToRemove }, }, workflowId: activeWorkflowId, userId, }) - workflowStore.removeEdge(edgeId) - } else { - logger.debug('Redo remove-edge skipped; edge missing', { - edgeId, - }) + edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) } + + logger.debug('Redid batch-remove-edges', { edgeCount: edgesToRemove.length }) break } - case 'move-block': { - const moveOp = entry.operation as MoveBlockOperation + case 'batch-move-blocks': { + const batchMoveOp = entry.operation as BatchMoveBlocksOperation const currentBlocks = useWorkflowStore.getState().blocks - if (currentBlocks[moveOp.data.blockId]) { + const positionUpdates: Array<{ id: string; position: { x: number; y: number } }> = [] + + for (const move of batchMoveOp.data.moves) { + if (currentBlocks[move.blockId]) { + positionUpdates.push({ + id: move.blockId, + position: { x: move.after.x, y: move.after.y }, + }) + workflowStore.updateBlockPosition(move.blockId, { + x: move.after.x, + y: move.after.y, + }) + } + } + + if (positionUpdates.length > 0) { addToQueue({ id: opId, operation: { - operation: 'update-position', - target: 'block', - payload: { - id: moveOp.data.blockId, - position: { x: moveOp.data.after.x, y: moveOp.data.after.y }, - parentId: moveOp.data.after.parentId, - commit: true, - isRedo: true, - originalOpId: entry.id, - }, + operation: 'batch-update-positions', + target: 'blocks', + payload: { updates: positionUpdates }, }, workflowId: activeWorkflowId, userId, }) - // Use the store from the hook context for React re-renders - workflowStore.updateBlockPosition(moveOp.data.blockId, { - x: moveOp.data.after.x, - y: moveOp.data.after.y, - }) - if (moveOp.data.after.parentId !== moveOp.data.before.parentId) { - workflowStore.updateParentId( - moveOp.data.blockId, - moveOp.data.after.parentId || '', - 'parent' - ) - } - } else { - logger.debug('Redo move-block skipped; block missing', { - blockId: moveOp.data.blockId, - }) } break } @@ -1036,27 +1150,86 @@ export function useUndoRedo() { // If we're adding TO a subflow, restore edges after if (newParentId && affectedEdges && affectedEdges.length > 0) { - affectedEdges.forEach((edge) => { - if (!workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.addEdge(edge) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'add', - target: 'edge', - payload: { ...edge, isRedo: true }, - }, - workflowId: activeWorkflowId, - userId, - }) - } - }) + const edgesToAdd = affectedEdges.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + if (edgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } } } else { logger.debug('Redo update-parent skipped; block missing', { blockId }) } break } + case 'batch-toggle-enabled': { + const toggleOp = entry.operation as BatchToggleEnabledOperation + const { blockIds, previousStates } = toggleOp.data + + const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (validBlockIds.length === 0) { + logger.debug('Redo batch-toggle-enabled skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: 'batch-toggle-enabled', + target: 'blocks', + payload: { blockIds: validBlockIds, previousStates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + validBlockIds.forEach((blockId) => { + const targetState = !previousStates[blockId] + if (workflowStore.blocks[blockId].enabled !== targetState) { + workflowStore.toggleBlockEnabled(blockId) + } + }) + break + } + case 'batch-toggle-handles': { + const toggleOp = entry.operation as BatchToggleHandlesOperation + const { blockIds, previousStates } = toggleOp.data + + const validBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (validBlockIds.length === 0) { + logger.debug('Redo batch-toggle-handles skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: 'batch-toggle-handles', + target: 'blocks', + payload: { blockIds: validBlockIds, previousStates }, + }, + workflowId: activeWorkflowId, + userId, + }) + + validBlockIds.forEach((blockId) => { + const targetState = !previousStates[blockId] + if (workflowStore.blocks[blockId].horizontalHandles !== targetState) { + workflowStore.toggleBlockHandles(blockId) + } + }) + break + } case 'apply-diff': { // Redo apply-diff means re-applying the proposed state with diff markers const applyDiffOp = entry.operation as any @@ -1372,9 +1545,11 @@ export function useUndoRedo() { recordBatchAddBlocks, recordBatchRemoveBlocks, recordAddEdge, - recordRemoveEdge, - recordMove, + recordBatchRemoveEdges, + recordBatchMoveBlocks, recordUpdateParent, + recordBatchToggleEnabled, + recordBatchToggleHandles, recordApplyDiff, recordAcceptDiff, recordRejectDiff, diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index 014cf08a63..865e51c6b6 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -179,6 +179,9 @@ export async function persistWorkflowOperation(workflowId: string, operation: an case 'edge': await handleEdgeOperationTx(tx, workflowId, op, payload) break + case 'edges': + await handleEdgesOperationTx(tx, workflowId, op, payload) + break case 'subflow': await handleSubflowOperationTx(tx, workflowId, op, payload) break @@ -690,6 +693,68 @@ async function handleBlocksOperationTx( break } + case 'batch-toggle-enabled': { + const { blockIds } = payload + if (!Array.isArray(blockIds) || blockIds.length === 0) { + return + } + + logger.info( + `Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}` + ) + + for (const blockId of blockIds) { + const block = await tx + .select({ enabled: workflowBlocks.enabled }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (block.length > 0) { + await tx + .update(workflowBlocks) + .set({ + enabled: !block[0].enabled, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + } + } + + logger.debug(`Batch toggled enabled state for ${blockIds.length} blocks`) + break + } + + case 'batch-toggle-handles': { + const { blockIds } = payload + if (!Array.isArray(blockIds) || blockIds.length === 0) { + return + } + + logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`) + + for (const blockId of blockIds) { + const block = await tx + .select({ horizontalHandles: workflowBlocks.horizontalHandles }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (block.length > 0) { + await tx + .update(workflowBlocks) + .set({ + horizontalHandles: !block[0].horizontalHandles, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) + } + } + + logger.debug(`Batch toggled handles for ${blockIds.length} blocks`) + break + } + default: throw new Error(`Unsupported blocks operation: ${operation}`) } @@ -740,6 +805,60 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str } } +async function handleEdgesOperationTx( + tx: any, + workflowId: string, + operation: string, + payload: any +) { + switch (operation) { + case 'batch-remove-edges': { + const { ids } = payload + if (!Array.isArray(ids) || ids.length === 0) { + logger.debug('No edge IDs provided for batch remove') + return + } + + logger.info(`Batch removing ${ids.length} edges from workflow ${workflowId}`) + + await tx + .delete(workflowEdges) + .where(and(eq(workflowEdges.workflowId, workflowId), inArray(workflowEdges.id, ids))) + + logger.debug(`Batch removed ${ids.length} edges from workflow ${workflowId}`) + break + } + + case 'batch-add-edges': { + const { edges } = payload + if (!Array.isArray(edges) || edges.length === 0) { + logger.debug('No edges provided for batch add') + return + } + + logger.info(`Batch adding ${edges.length} edges to workflow ${workflowId}`) + + const edgeValues = edges.map((edge: Record) => ({ + id: edge.id as string, + workflowId, + sourceBlockId: edge.source as string, + targetBlockId: edge.target as string, + sourceHandle: (edge.sourceHandle as string | null) || null, + targetHandle: (edge.targetHandle as string | null) || null, + })) + + await tx.insert(workflowEdges).values(edgeValues) + + logger.debug(`Batch added ${edges.length} edges to workflow ${workflowId}`) + break + } + + default: + logger.warn(`Unknown edges operation: ${operation}`) + throw new Error(`Unsupported edges operation: ${operation}`) + } +} + async function handleSubflowOperationTx( tx: any, workflowId: string, diff --git a/apps/sim/socket/handlers/operations.ts b/apps/sim/socket/handlers/operations.ts index 7fd64995c9..eaeeab3062 100644 --- a/apps/sim/socket/handlers/operations.ts +++ b/apps/sim/socket/handlers/operations.ts @@ -317,6 +317,122 @@ export function setupOperationsHandlers( return } + if (target === 'edges' && operation === 'batch-remove-edges') { + 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 === 'blocks' && operation === 'batch-toggle-enabled') { + 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 === 'blocks' && operation === 'batch-toggle-handles') { + 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 === 'edges' && operation === 'batch-add-edges') { + 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 + } + // For non-position operations, persist first then broadcast await persistWorkflowOperation(workflowId, { operation, diff --git a/apps/sim/socket/middleware/permissions.ts b/apps/sim/socket/middleware/permissions.ts index 02aadfdde8..3a43010b99 100644 --- a/apps/sim/socket/middleware/permissions.ts +++ b/apps/sim/socket/middleware/permissions.ts @@ -16,6 +16,10 @@ const ROLE_PERMISSIONS: Record = { 'batch-update-positions', 'batch-add-blocks', 'batch-remove-blocks', + 'batch-add-edges', + 'batch-remove-edges', + 'batch-toggle-enabled', + 'batch-toggle-handles', 'update-name', 'toggle-enabled', 'update-parent', @@ -33,6 +37,10 @@ const ROLE_PERMISSIONS: Record = { 'batch-update-positions', 'batch-add-blocks', 'batch-remove-blocks', + 'batch-add-edges', + 'batch-remove-edges', + 'batch-toggle-enabled', + 'batch-toggle-handles', 'update-name', 'toggle-enabled', 'update-parent', diff --git a/apps/sim/socket/validation/schemas.ts b/apps/sim/socket/validation/schemas.ts index 71be529774..e1bd5f520f 100644 --- a/apps/sim/socket/validation/schemas.ts +++ b/apps/sim/socket/validation/schemas.ts @@ -5,7 +5,6 @@ const PositionSchema = z.object({ y: z.number(), }) -// Schema for auto-connect edge data const AutoConnectEdgeSchema = z.object({ id: z.string(), source: z.string(), @@ -148,12 +147,66 @@ export const BatchRemoveBlocksSchema = z.object({ operationId: z.string().optional(), }) +export const BatchRemoveEdgesSchema = z.object({ + operation: z.literal('batch-remove-edges'), + target: z.literal('edges'), + payload: z.object({ + ids: z.array(z.string()), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchAddEdgesSchema = z.object({ + operation: z.literal('batch-add-edges'), + target: z.literal('edges'), + payload: z.object({ + edges: z.array( + z.object({ + id: z.string(), + source: z.string(), + target: z.string(), + sourceHandle: z.string().nullable().optional(), + targetHandle: z.string().nullable().optional(), + }) + ), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchToggleEnabledSchema = z.object({ + operation: z.literal('batch-toggle-enabled'), + target: z.literal('blocks'), + payload: z.object({ + blockIds: z.array(z.string()), + previousStates: z.record(z.boolean()), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchToggleHandlesSchema = z.object({ + operation: z.literal('batch-toggle-handles'), + target: z.literal('blocks'), + payload: z.object({ + blockIds: z.array(z.string()), + previousStates: z.record(z.boolean()), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + export const WorkflowOperationSchema = z.union([ BlockOperationSchema, BatchPositionUpdateSchema, BatchAddBlocksSchema, BatchRemoveBlocksSchema, + BatchToggleEnabledSchema, + BatchToggleHandlesSchema, EdgeOperationSchema, + BatchAddEdgesSchema, + BatchRemoveEdgesSchema, SubflowOperationSchema, VariableOperationSchema, WorkflowStateOperationSchema, diff --git a/apps/sim/stores/undo-redo/store.test.ts b/apps/sim/stores/undo-redo/store.test.ts index 99f6f3827a..b583a01585 100644 --- a/apps/sim/stores/undo-redo/store.test.ts +++ b/apps/sim/stores/undo-redo/store.test.ts @@ -751,7 +751,7 @@ describe('useUndoRedoStore', () => { expect(getStackSizes(workflowId, userId).undoSize).toBe(3) const moveEntry = undo(workflowId, userId) - expect(moveEntry?.operation.type).toBe('move-block') + expect(moveEntry?.operation.type).toBe('batch-move-blocks') const parentEntry = undo(workflowId, userId) expect(parentEntry?.operation.type).toBe('update-parent') diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts index 07c67a0f5b..0d01f66d82 100644 --- a/apps/sim/stores/undo-redo/store.ts +++ b/apps/sim/stores/undo-redo/store.ts @@ -4,11 +4,11 @@ import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' import type { BatchAddBlocksOperation, + BatchMoveBlocksOperation, BatchRemoveBlocksOperation, - MoveBlockOperation, + BatchRemoveEdgesOperation, Operation, OperationEntry, - RemoveEdgeOperation, UndoRedoState, } from '@/stores/undo-redo/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -92,17 +92,17 @@ function isOperationApplicable( const op = operation as BatchAddBlocksOperation return op.data.blockSnapshots.every((block) => !graph.blocksById[block.id]) } - case 'move-block': { - const op = operation as MoveBlockOperation - return Boolean(graph.blocksById[op.data.blockId]) + case 'batch-move-blocks': { + const op = operation as BatchMoveBlocksOperation + return op.data.moves.every((move) => Boolean(graph.blocksById[move.blockId])) } case 'update-parent': { const blockId = operation.data.blockId return Boolean(graph.blocksById[blockId]) } - case 'remove-edge': { - const op = operation as RemoveEdgeOperation - return Boolean(graph.edgesById[op.data.edgeId]) + case 'batch-remove-edges': { + const op = operation as BatchRemoveEdgesOperation + return op.data.edgeSnapshots.every((edge) => Boolean(graph.edgesById[edge.id])) } case 'add-edge': { const edgeId = operation.data.edgeId @@ -198,62 +198,82 @@ export const useUndoRedoStore = create()( } } - // Coalesce consecutive move-block operations for the same block - if (entry.operation.type === 'move-block') { - const incoming = entry.operation as MoveBlockOperation + // Coalesce consecutive batch-move-blocks operations for overlapping blocks + if (entry.operation.type === 'batch-move-blocks') { + const incoming = entry.operation as BatchMoveBlocksOperation const last = stack.undo[stack.undo.length - 1] - // Skip no-op moves - const b1 = incoming.data.before - const a1 = incoming.data.after - const sameParent = (b1.parentId ?? null) === (a1.parentId ?? null) - if (b1.x === a1.x && b1.y === a1.y && sameParent) { - logger.debug('Skipped no-op move push') + // Skip no-op moves (all moves have same before/after) + const allNoOp = incoming.data.moves.every((move) => { + const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null) + return move.before.x === move.after.x && move.before.y === move.after.y && sameParent + }) + if (allNoOp) { + logger.debug('Skipped no-op batch move push') return } - if (last && last.operation.type === 'move-block' && last.inverse.type === 'move-block') { - const prev = last.operation as MoveBlockOperation - if (prev.data.blockId === incoming.data.blockId) { - // Merge: keep earliest before, latest after - const mergedBefore = prev.data.before - const mergedAfter = incoming.data.after + if ( + last && + last.operation.type === 'batch-move-blocks' && + last.inverse.type === 'batch-move-blocks' + ) { + const prev = last.operation as BatchMoveBlocksOperation + const prevBlockIds = new Set(prev.data.moves.map((m) => m.blockId)) + const incomingBlockIds = new Set(incoming.data.moves.map((m) => m.blockId)) + + // Check if same set of blocks + const sameBlocks = + prevBlockIds.size === incomingBlockIds.size && + [...prevBlockIds].every((id) => incomingBlockIds.has(id)) + + if (sameBlocks) { + // Merge: keep earliest before, latest after for each block + const mergedMoves = incoming.data.moves.map((incomingMove) => { + const prevMove = prev.data.moves.find((m) => m.blockId === incomingMove.blockId)! + return { + blockId: incomingMove.blockId, + before: prevMove.before, + after: incomingMove.after, + } + }) - const sameAfter = - mergedBefore.x === mergedAfter.x && - mergedBefore.y === mergedAfter.y && - (mergedBefore.parentId ?? null) === (mergedAfter.parentId ?? null) + // Check if all moves result in same position (net no-op) + const allSameAfter = mergedMoves.every((move) => { + const sameParent = (move.before.parentId ?? null) === (move.after.parentId ?? null) + return ( + move.before.x === move.after.x && move.before.y === move.after.y && sameParent + ) + }) - const newUndoCoalesced: OperationEntry[] = sameAfter + const newUndoCoalesced: OperationEntry[] = allSameAfter ? stack.undo.slice(0, -1) : (() => { - const op = entry.operation as MoveBlockOperation - const inv = entry.inverse as MoveBlockOperation + const op = entry.operation as BatchMoveBlocksOperation + const inv = entry.inverse as BatchMoveBlocksOperation const newEntry: OperationEntry = { id: entry.id, createdAt: entry.createdAt, operation: { id: op.id, - type: 'move-block', + type: 'batch-move-blocks', timestamp: op.timestamp, workflowId, userId, - data: { - blockId: incoming.data.blockId, - before: mergedBefore, - after: mergedAfter, - }, + data: { moves: mergedMoves }, }, inverse: { id: inv.id, - type: 'move-block', + type: 'batch-move-blocks', timestamp: inv.timestamp, workflowId, userId, data: { - blockId: incoming.data.blockId, - before: mergedAfter, - after: mergedBefore, + moves: mergedMoves.map((m) => ({ + blockId: m.blockId, + before: m.after, + after: m.before, + })), }, }, } @@ -268,10 +288,10 @@ export const useUndoRedoStore = create()( set({ stacks: currentStacks }) - logger.debug('Coalesced consecutive move operations', { + logger.debug('Coalesced consecutive batch move operations', { workflowId, userId, - blockId: incoming.data.blockId, + blockCount: mergedMoves.length, undoSize: newUndoCoalesced.length, }) return diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index d69688e838..910a3f6e88 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -5,12 +5,14 @@ export type OperationType = | 'batch-add-blocks' | 'batch-remove-blocks' | 'add-edge' - | 'remove-edge' + | 'batch-remove-edges' | 'add-subflow' | 'remove-subflow' - | 'move-block' + | 'batch-move-blocks' | 'move-subflow' | 'update-parent' + | 'batch-toggle-enabled' + | 'batch-toggle-handles' | 'apply-diff' | 'accept-diff' | 'reject-diff' @@ -48,11 +50,10 @@ export interface AddEdgeOperation extends BaseOperation { } } -export interface RemoveEdgeOperation extends BaseOperation { - type: 'remove-edge' +export interface BatchRemoveEdgesOperation extends BaseOperation { + type: 'batch-remove-edges' data: { - edgeId: string - edgeSnapshot: Edge | null + edgeSnapshots: Edge[] } } @@ -71,20 +72,14 @@ export interface RemoveSubflowOperation extends BaseOperation { } } -export interface MoveBlockOperation extends BaseOperation { - type: 'move-block' +export interface BatchMoveBlocksOperation extends BaseOperation { + type: 'batch-move-blocks' data: { - blockId: string - before: { - x: number - y: number - parentId?: string - } - after: { - x: number - y: number - parentId?: string - } + moves: Array<{ + blockId: string + before: { x: number; y: number; parentId?: string } + after: { x: number; y: number; parentId?: string } + }> } } @@ -115,6 +110,22 @@ export interface UpdateParentOperation extends BaseOperation { } } +export interface BatchToggleEnabledOperation extends BaseOperation { + type: 'batch-toggle-enabled' + data: { + blockIds: string[] + previousStates: Record + } +} + +export interface BatchToggleHandlesOperation extends BaseOperation { + type: 'batch-toggle-handles' + data: { + blockIds: string[] + previousStates: Record + } +} + export interface ApplyDiffOperation extends BaseOperation { type: 'apply-diff' data: { @@ -148,12 +159,14 @@ export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation | AddEdgeOperation - | RemoveEdgeOperation + | BatchRemoveEdgesOperation | AddSubflowOperation | RemoveSubflowOperation - | MoveBlockOperation + | BatchMoveBlocksOperation | MoveSubflowOperation | UpdateParentOperation + | BatchToggleEnabledOperation + | BatchToggleHandlesOperation | ApplyDiffOperation | AcceptDiffOperation | RejectDiffOperation diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index e00209c203..c36ed933d9 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -1,6 +1,8 @@ import type { BatchAddBlocksOperation, + BatchMoveBlocksOperation, BatchRemoveBlocksOperation, + BatchRemoveEdgesOperation, Operation, OperationEntry, } from '@/stores/undo-redo/types' @@ -43,23 +45,27 @@ export function createInverseOperation(operation: Operation): Operation { } case 'add-edge': + // Note: add-edge only stores edgeId. The full edge snapshot is stored + // in the inverse operation when recording. This function can't create + // a complete inverse without the snapshot. return { ...operation, - type: 'remove-edge', + type: 'batch-remove-edges', data: { - edgeId: operation.data.edgeId, - edgeSnapshot: null, + edgeSnapshots: [], }, - } + } as BatchRemoveEdgesOperation - case 'remove-edge': + case 'batch-remove-edges': { + const op = operation as BatchRemoveEdgesOperation return { ...operation, - type: 'add-edge', + type: 'batch-remove-edges', data: { - edgeId: operation.data.edgeId, + edgeSnapshots: op.data.edgeSnapshots, }, - } + } as BatchRemoveEdgesOperation + } case 'add-subflow': return { @@ -80,15 +86,20 @@ export function createInverseOperation(operation: Operation): Operation { }, } - case 'move-block': + case 'batch-move-blocks': { + const op = operation as BatchMoveBlocksOperation return { ...operation, + type: 'batch-move-blocks', data: { - blockId: operation.data.blockId, - before: operation.data.after, - after: operation.data.before, + moves: op.data.moves.map((m) => ({ + blockId: m.blockId, + before: m.after, + after: m.before, + })), }, - } + } as BatchMoveBlocksOperation + } case 'move-subflow': return { @@ -145,6 +156,24 @@ export function createInverseOperation(operation: Operation): Operation { }, } + case 'batch-toggle-enabled': + return { + ...operation, + data: { + blockIds: operation.data.blockIds, + previousStates: operation.data.previousStates, + }, + } + + case 'batch-toggle-handles': + return { + ...operation, + data: { + blockIds: operation.data.blockIds, + previousStates: operation.data.previousStates, + }, + } + default: { const exhaustiveCheck: never = operation throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) @@ -189,12 +218,14 @@ export function operationToCollaborativePayload(operation: Operation): { payload: { id: operation.data.edgeId }, } - case 'remove-edge': + case 'batch-remove-edges': { + const op = operation as BatchRemoveEdgesOperation return { - operation: 'remove', - target: 'edge', - payload: { id: operation.data.edgeId }, + operation: 'batch-remove-edges', + target: 'edges', + payload: { ids: op.data.edgeSnapshots.map((e) => e.id) }, } + } case 'add-subflow': return { @@ -210,17 +241,21 @@ export function operationToCollaborativePayload(operation: Operation): { payload: { id: operation.data.subflowId }, } - case 'move-block': + case 'batch-move-blocks': { + const op = operation as BatchMoveBlocksOperation return { - operation: 'update-position', - target: 'block', + operation: 'batch-update-positions', + target: 'blocks', payload: { - id: operation.data.blockId, - x: operation.data.after.x, - y: operation.data.after.y, - parentId: operation.data.after.parentId, + moves: op.data.moves.map((m) => ({ + id: m.blockId, + x: m.after.x, + y: m.after.y, + parentId: m.after.parentId, + })), }, } + } case 'move-subflow': return { @@ -272,6 +307,26 @@ export function operationToCollaborativePayload(operation: Operation): { }, } + case 'batch-toggle-enabled': + return { + operation: 'batch-toggle-enabled', + target: 'blocks', + payload: { + blockIds: operation.data.blockIds, + previousStates: operation.data.previousStates, + }, + } + + case 'batch-toggle-handles': + return { + operation: 'batch-toggle-handles', + target: 'blocks', + payload: { + blockIds: operation.data.blockIds, + previousStates: operation.data.previousStates, + }, + } + default: { const exhaustiveCheck: never = operation throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) diff --git a/packages/testing/src/factories/index.ts b/packages/testing/src/factories/index.ts index 4a7f456b16..45854fd06e 100644 --- a/packages/testing/src/factories/index.ts +++ b/packages/testing/src/factories/index.ts @@ -123,6 +123,7 @@ export { type AddEdgeOperation, type BaseOperation, type BatchAddBlocksOperation, + type BatchMoveBlocksOperation, type BatchRemoveBlocksOperation, createAddBlockEntry, createAddEdgeEntry, @@ -130,7 +131,6 @@ export { createRemoveBlockEntry, createRemoveEdgeEntry, createUpdateParentEntry, - type MoveBlockOperation, type Operation, type OperationEntry, type OperationType, diff --git a/packages/testing/src/factories/undo-redo.factory.ts b/packages/testing/src/factories/undo-redo.factory.ts index d03c8cefe6..e55ace2475 100644 --- a/packages/testing/src/factories/undo-redo.factory.ts +++ b/packages/testing/src/factories/undo-redo.factory.ts @@ -10,7 +10,7 @@ export type OperationType = | 'batch-remove-blocks' | 'add-edge' | 'remove-edge' - | 'move-block' + | 'batch-move-blocks' | 'update-parent' /** @@ -25,14 +25,16 @@ export interface BaseOperation { } /** - * Move block operation data. + * Batch move blocks operation data. */ -export interface MoveBlockOperation extends BaseOperation { - type: 'move-block' +export interface BatchMoveBlocksOperation extends BaseOperation { + type: 'batch-move-blocks' data: { - blockId: string - before: { x: number; y: number; parentId?: string } - after: { x: number; y: number; parentId?: string } + moves: Array<{ + blockId: string + before: { x: number; y: number; parentId?: string } + after: { x: number; y: number; parentId?: string } + }> } } @@ -95,7 +97,7 @@ export type Operation = | BatchRemoveBlocksOperation | AddEdgeOperation | RemoveEdgeOperation - | MoveBlockOperation + | BatchMoveBlocksOperation | UpdateParentOperation /** @@ -275,7 +277,7 @@ interface MoveBlockOptions extends OperationEntryOptions { } /** - * Creates a mock move-block operation entry. + * Creates a mock batch-move-blocks operation entry for a single block. */ export function createMoveBlockEntry(blockId: string, options: MoveBlockOptions = {}): any { const { @@ -293,19 +295,19 @@ export function createMoveBlockEntry(blockId: string, options: MoveBlockOptions createdAt, operation: { id: nanoid(8), - type: 'move-block', + type: 'batch-move-blocks', timestamp, workflowId, userId, - data: { blockId, before, after }, + data: { moves: [{ blockId, before, after }] }, }, inverse: { id: nanoid(8), - type: 'move-block', + type: 'batch-move-blocks', timestamp, workflowId, userId, - data: { blockId, before: after, after: before }, + data: { moves: [{ blockId, before: after, after: before }] }, }, } } From aca957994a77954e4d0c57ec8f58325fcb4bdb00 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 18:18:19 -0800 Subject: [PATCH 14/35] don't allow flip handles for subflows --- .../[workflowId]/components/context-menu/block-context-menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx index 6ae1f22e09..547098f7bd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/context-menu/block-context-menu.tsx @@ -118,7 +118,7 @@ export function BlockContextMenu({ {getToggleEnabledLabel()} )} - {!allNoteBlocks && ( + {!allNoteBlocks && !isSubflow && ( { From b11f1cbfde83c26b9f688c3af3dcb35892bd58e0 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 18:34:07 -0800 Subject: [PATCH 15/35] ack PR comments --- apps/sim/hooks/use-undo-redo.ts | 7 ++-- apps/sim/socket/database/operations.ts | 58 ++++++++++++-------------- apps/sim/stores/undo-redo/types.ts | 9 ++++ apps/sim/stores/undo-redo/utils.ts | 33 ++++++++++++++- 4 files changed, 69 insertions(+), 38 deletions(-) diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index aa40c9c70f..536941c720 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -6,6 +6,7 @@ import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-o import { useOperationQueue } from '@/stores/operation-queue/store' import { type BatchAddBlocksOperation, + type BatchAddEdgesOperation, type BatchMoveBlocksOperation, type BatchRemoveBlocksOperation, type BatchRemoveEdgesOperation, @@ -141,7 +142,6 @@ export function useUndoRedo() { data: { edgeId }, } - // Inverse is batch-remove-edges with a single edge const inverse: BatchRemoveEdgesOperation = { id: crypto.randomUUID(), type: 'batch-remove-edges', @@ -176,10 +176,9 @@ export function useUndoRedo() { }, } - // Inverse is batch-add-edges (using the snapshots to restore) - const inverse: BatchRemoveEdgesOperation = { + const inverse: BatchAddEdgesOperation = { id: crypto.randomUUID(), - type: 'batch-remove-edges', + type: 'batch-add-edges', timestamp: Date.now(), workflowId: activeWorkflowId, userId, diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index 865e51c6b6..cdd4ff1872 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -703,25 +703,22 @@ async function handleBlocksOperationTx( `Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}` ) - for (const blockId of blockIds) { - const block = await tx - .select({ enabled: workflowBlocks.enabled }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) + const blocks = await tx + .select({ id: workflowBlocks.id, enabled: workflowBlocks.enabled }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds))) - if (block.length > 0) { - await tx - .update(workflowBlocks) - .set({ - enabled: !block[0].enabled, - updatedAt: new Date(), - }) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - } + for (const block of blocks) { + await tx + .update(workflowBlocks) + .set({ + enabled: !block.enabled, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId))) } - logger.debug(`Batch toggled enabled state for ${blockIds.length} blocks`) + logger.debug(`Batch toggled enabled state for ${blocks.length} blocks`) break } @@ -733,25 +730,22 @@ async function handleBlocksOperationTx( logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`) - for (const blockId of blockIds) { - const block = await tx - .select({ horizontalHandles: workflowBlocks.horizontalHandles }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) + const blocks = await tx + .select({ id: workflowBlocks.id, horizontalHandles: workflowBlocks.horizontalHandles }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds))) - if (block.length > 0) { - await tx - .update(workflowBlocks) - .set({ - horizontalHandles: !block[0].horizontalHandles, - updatedAt: new Date(), - }) - .where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))) - } + for (const block of blocks) { + await tx + .update(workflowBlocks) + .set({ + horizontalHandles: !block.horizontalHandles, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId))) } - logger.debug(`Batch toggled handles for ${blockIds.length} blocks`) + logger.debug(`Batch toggled handles for ${blocks.length} blocks`) break } diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index 910a3f6e88..7973f48587 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -5,6 +5,7 @@ export type OperationType = | 'batch-add-blocks' | 'batch-remove-blocks' | 'add-edge' + | 'batch-add-edges' | 'batch-remove-edges' | 'add-subflow' | 'remove-subflow' @@ -50,6 +51,13 @@ export interface AddEdgeOperation extends BaseOperation { } } +export interface BatchAddEdgesOperation extends BaseOperation { + type: 'batch-add-edges' + data: { + edgeSnapshots: Edge[] + } +} + export interface BatchRemoveEdgesOperation extends BaseOperation { type: 'batch-remove-edges' data: { @@ -159,6 +167,7 @@ export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation | AddEdgeOperation + | BatchAddEdgesOperation | BatchRemoveEdgesOperation | AddSubflowOperation | RemoveSubflowOperation diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index c36ed933d9..d2d5e40876 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -1,5 +1,6 @@ import type { BatchAddBlocksOperation, + BatchAddEdgesOperation, BatchMoveBlocksOperation, BatchRemoveBlocksOperation, BatchRemoveEdgesOperation, @@ -56,8 +57,8 @@ export function createInverseOperation(operation: Operation): Operation { }, } as BatchRemoveEdgesOperation - case 'batch-remove-edges': { - const op = operation as BatchRemoveEdgesOperation + case 'batch-add-edges': { + const op = operation as BatchAddEdgesOperation return { ...operation, type: 'batch-remove-edges', @@ -67,6 +68,17 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchRemoveEdgesOperation } + case 'batch-remove-edges': { + const op = operation as BatchRemoveEdgesOperation + return { + ...operation, + type: 'batch-add-edges', + data: { + edgeSnapshots: op.data.edgeSnapshots, + }, + } as BatchAddEdgesOperation + } + case 'add-subflow': return { ...operation, @@ -218,6 +230,23 @@ export function operationToCollaborativePayload(operation: Operation): { payload: { id: operation.data.edgeId }, } + case 'batch-add-edges': { + const op = operation as BatchAddEdgesOperation + return { + operation: 'batch-add-edges', + target: 'edges', + payload: { + edges: op.data.edgeSnapshots.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + sourceHandle: e.sourceHandle ?? null, + targetHandle: e.targetHandle ?? null, + })), + }, + } + } + case 'batch-remove-edges': { const op = operation as BatchRemoveEdgesOperation return { From 06c007f9f38e0275f37e3362403c6f135291b24d Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 18:56:27 -0800 Subject: [PATCH 16/35] more --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 13 +++--- apps/sim/hooks/use-undo-redo.ts | 28 ++++++------- apps/sim/stores/workflows/workflow/store.ts | 41 +++++++++++++++++++ apps/sim/stores/workflows/workflow/types.ts | 2 + 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 8aa8cde388..ce3a807c4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2408,11 +2408,14 @@ const WorkflowContent = React.memo(() => { const selectedNodes = allNodes.filter((n) => n.selected) multiNodeDragStartRef.current.clear() selectedNodes.forEach((n) => { - multiNodeDragStartRef.current.set(n.id, { - x: n.position.x, - y: n.position.y, - parentId: blocks[n.id]?.data?.parentId, - }) + const block = blocks[n.id] + if (block) { + multiNodeDragStartRef.current.set(n.id, { + x: n.position.x, + y: n.position.y, + parentId: block.data?.parentId, + }) + } }) }, [blocks, setDragStartPosition, getNodes] diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 536941c720..318b752a3c 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -682,11 +682,10 @@ export function useUndoRedo() { userId, }) + // Use setBlockEnabled to directly restore to previous state + // This is more robust than conditional toggle in collaborative scenarios validBlockIds.forEach((blockId) => { - const targetState = previousStates[blockId] - if (workflowStore.blocks[blockId].enabled !== targetState) { - workflowStore.toggleBlockEnabled(blockId) - } + workflowStore.setBlockEnabled(blockId, previousStates[blockId]) }) break } @@ -711,11 +710,10 @@ export function useUndoRedo() { userId, }) + // Use setBlockHandles to directly restore to previous state + // This is more robust than conditional toggle in collaborative scenarios validBlockIds.forEach((blockId) => { - const targetState = previousStates[blockId] - if (workflowStore.blocks[blockId].horizontalHandles !== targetState) { - workflowStore.toggleBlockHandles(blockId) - } + workflowStore.setBlockHandles(blockId, previousStates[blockId]) }) break } @@ -1192,11 +1190,10 @@ export function useUndoRedo() { userId, }) + // Use setBlockEnabled to directly set to toggled state + // Redo sets to !previousStates (the state after the original toggle) validBlockIds.forEach((blockId) => { - const targetState = !previousStates[blockId] - if (workflowStore.blocks[blockId].enabled !== targetState) { - workflowStore.toggleBlockEnabled(blockId) - } + workflowStore.setBlockEnabled(blockId, !previousStates[blockId]) }) break } @@ -1221,11 +1218,10 @@ export function useUndoRedo() { userId, }) + // Use setBlockHandles to directly set to toggled state + // Redo sets to !previousStates (the state after the original toggle) validBlockIds.forEach((blockId) => { - const targetState = !previousStates[blockId] - if (workflowStore.blocks[blockId].horizontalHandles !== targetState) { - workflowStore.toggleBlockHandles(blockId) - } + workflowStore.setBlockHandles(blockId, !previousStates[blockId]) }) break } diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 41d3051637..a217964b36 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -586,6 +586,27 @@ export const useWorkflowStore = create()( // Note: Socket.IO handles real-time sync automatically }, + setBlockEnabled: (id: string, enabled: boolean) => { + const block = get().blocks[id] + if (!block || block.enabled === enabled) return + + const newState = { + blocks: { + ...get().blocks, + [id]: { + ...block, + enabled, + }, + }, + edges: [...get().edges], + loops: { ...get().loops }, + parallels: { ...get().parallels }, + } + + set(newState) + get().updateLastSaved() + }, + duplicateBlock: (id: string) => { const block = get().blocks[id] if (!block) return @@ -668,6 +689,26 @@ export const useWorkflowStore = create()( // Note: Socket.IO handles real-time sync automatically }, + setBlockHandles: (id: string, horizontalHandles: boolean) => { + const block = get().blocks[id] + if (!block || block.horizontalHandles === horizontalHandles) return + + const newState = { + blocks: { + ...get().blocks, + [id]: { + ...block, + horizontalHandles, + }, + }, + edges: [...get().edges], + loops: { ...get().loops }, + } + + set(newState) + get().updateLastSaved() + }, + updateBlockName: (id: string, name: string) => { const oldBlock = get().blocks[id] if (!oldBlock) return { success: false, changedSubblocks: [] } diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index c836b8040c..023a223dec 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -195,8 +195,10 @@ export interface WorkflowActions { clear: () => Partial updateLastSaved: () => void toggleBlockEnabled: (id: string) => void + setBlockEnabled: (id: string, enabled: boolean) => void duplicateBlock: (id: string) => void toggleBlockHandles: (id: string) => void + setBlockHandles: (id: string, horizontalHandles: boolean) => void updateBlockName: ( id: string, name: string From abf64aa9e725dd7498b4682c5de86666499cf0e5 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 19:02:30 -0800 Subject: [PATCH 17/35] fix missing handler --- apps/sim/hooks/use-undo-redo.ts | 107 ++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 39 deletions(-) diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 318b752a3c..bf6b2c236e 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -492,50 +492,53 @@ export function useUndoRedo() { break } case 'batch-remove-edges': { + // Undo add-edge: inverse is batch-remove-edges, so remove the edges const batchRemoveInverse = entry.inverse as BatchRemoveEdgesOperation const { edgeSnapshots } = batchRemoveInverse.data - if (entry.operation.type === 'add-edge') { - // Undo add-edge: remove the edges that were added - const edgesToRemove = edgeSnapshots - .filter((e) => workflowStore.edges.find((edge) => edge.id === e.id)) - .map((e) => e.id) - - if (edgesToRemove.length > 0) { - addToQueue({ - id: opId, - operation: { - operation: 'batch-remove-edges', - target: 'edges', - payload: { ids: edgesToRemove }, - }, - workflowId: activeWorkflowId, - userId, - }) - edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) - } - logger.debug('Undid add-edge', { edgeCount: edgesToRemove.length }) - } else { - // Undo batch-remove-edges: add edges back - const edgesToAdd = edgeSnapshots.filter( - (e) => !workflowStore.edges.find((edge) => edge.id === e.id) - ) + const edgesToRemove = edgeSnapshots + .filter((e) => workflowStore.edges.find((edge) => edge.id === e.id)) + .map((e) => e.id) - if (edgesToAdd.length > 0) { - addToQueue({ - id: opId, - operation: { - operation: 'batch-add-edges', - target: 'edges', - payload: { edges: edgesToAdd }, - }, - workflowId: activeWorkflowId, - userId, - }) - edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) - } - logger.debug('Undid batch-remove-edges', { edgeCount: edgesToAdd.length }) + if (edgesToRemove.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: 'batch-remove-edges', + target: 'edges', + payload: { ids: edgesToRemove }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) + } + logger.debug('Undid add-edge', { edgeCount: edgesToRemove.length }) + break + } + case 'batch-add-edges': { + // Undo batch-remove-edges: inverse is batch-add-edges, so add edges back + const batchAddInverse = entry.inverse as BatchAddEdgesOperation + const { edgeSnapshots } = batchAddInverse.data + + const edgesToAdd = edgeSnapshots.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + + if (edgesToAdd.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) } + logger.debug('Undid batch-remove-edges', { edgeCount: edgesToAdd.length }) break } case 'batch-move-blocks': { @@ -1046,6 +1049,32 @@ export function useUndoRedo() { logger.debug('Redid batch-remove-edges', { edgeCount: edgesToRemove.length }) break } + case 'batch-add-edges': { + // Redo batch-add-edges: add all edges again + const batchAddOp = entry.operation as BatchAddEdgesOperation + const { edgeSnapshots } = batchAddOp.data + + const edgesToAdd = edgeSnapshots.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + + if (edgesToAdd.length > 0) { + addToQueue({ + id: opId, + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } + + logger.debug('Redid batch-add-edges', { edgeCount: edgesToAdd.length }) + break + } case 'batch-move-blocks': { const batchMoveOp = entry.operation as BatchMoveBlocksOperation const currentBlocks = useWorkflowStore.getState().blocks From 8af27a7838d6114741a71990bfa3cc5da0c961e4 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 19:30:12 -0800 Subject: [PATCH 18/35] remove dead subflow-specific ops --- apps/sim/hooks/use-undo-redo.ts | 3 +- apps/sim/stores/undo-redo/store.test.ts | 19 ++++--- apps/sim/stores/undo-redo/store.ts | 12 ++--- apps/sim/stores/undo-redo/types.ts | 36 ------------- apps/sim/stores/undo-redo/utils.ts | 54 ------------------- packages/testing/src/factories/index.ts | 5 +- .../src/factories/undo-redo.factory.ts | 49 +++++++++++------ 7 files changed, 54 insertions(+), 124 deletions(-) diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index bf6b2c236e..4660ffeee2 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -425,7 +425,8 @@ export function useUndoRedo() { break } case 'batch-add-blocks': { - const batchAddOp = entry.operation as BatchAddBlocksOperation + // Undoing a removal: inverse is batch-add-blocks, use entry.inverse for data + const batchAddOp = entry.inverse as BatchAddBlocksOperation const { blockSnapshots, edgeSnapshots, subBlockValues } = batchAddOp.data const blocksToAdd = blockSnapshots.filter((b) => !workflowStore.blocks[b.id]) diff --git a/apps/sim/stores/undo-redo/store.test.ts b/apps/sim/stores/undo-redo/store.test.ts index b583a01585..3f8a5a3c07 100644 --- a/apps/sim/stores/undo-redo/store.test.ts +++ b/apps/sim/stores/undo-redo/store.test.ts @@ -13,11 +13,11 @@ import { createAddBlockEntry, createAddEdgeEntry, + createBatchRemoveEdgesEntry, createBlock, createMockStorage, createMoveBlockEntry, createRemoveBlockEntry, - createRemoveEdgeEntry, createUpdateParentEntry, } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' @@ -603,16 +603,17 @@ describe('useUndoRedoStore', () => { expect(getStackSizes(workflowId, userId).undoSize).toBe(2) }) - it('should handle remove-edge operations', () => { + it('should handle batch-remove-edges operations', () => { const { push, undo, getStackSizes } = useUndoRedoStore.getState() - push(workflowId, userId, createRemoveEdgeEntry('edge-1', null, { workflowId, userId })) + const edgeSnapshot = { id: 'edge-1', source: 'block-1', target: 'block-2' } + push(workflowId, userId, createBatchRemoveEdgesEntry([edgeSnapshot], { workflowId, userId })) expect(getStackSizes(workflowId, userId).undoSize).toBe(1) const entry = undo(workflowId, userId) - expect(entry?.operation.type).toBe('remove-edge') - expect(entry?.inverse.type).toBe('add-edge') + expect(entry?.operation.type).toBe('batch-remove-edges') + expect(entry?.inverse.type).toBe('batch-add-edges') }) }) @@ -672,8 +673,10 @@ describe('useUndoRedoStore', () => { it('should remove entries for non-existent edges', () => { const { push, pruneInvalidEntries, getStackSizes } = useUndoRedoStore.getState() - push(workflowId, userId, createRemoveEdgeEntry('edge-1', null, { workflowId, userId })) - push(workflowId, userId, createRemoveEdgeEntry('edge-2', null, { workflowId, userId })) + const edge1 = { id: 'edge-1', source: 'a', target: 'b' } + const edge2 = { id: 'edge-2', source: 'c', target: 'd' } + push(workflowId, userId, createBatchRemoveEdgesEntry([edge1], { workflowId, userId })) + push(workflowId, userId, createBatchRemoveEdgesEntry([edge2], { workflowId, userId })) expect(getStackSizes(workflowId, userId).undoSize).toBe(2) @@ -686,6 +689,8 @@ describe('useUndoRedoStore', () => { pruneInvalidEntries(workflowId, userId, graph as any) + // edge-1 exists in graph, so we can't undo its removal (can't add it back) → pruned + // edge-2 doesn't exist, so we can undo its removal (can add it back) → kept expect(getStackSizes(workflowId, userId).undoSize).toBe(1) }) }) diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts index 0d01f66d82..3aabbd8558 100644 --- a/apps/sim/stores/undo-redo/store.ts +++ b/apps/sim/stores/undo-redo/store.ts @@ -4,6 +4,7 @@ import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' import type { BatchAddBlocksOperation, + BatchAddEdgesOperation, BatchMoveBlocksOperation, BatchRemoveBlocksOperation, BatchRemoveEdgesOperation, @@ -104,17 +105,14 @@ function isOperationApplicable( const op = operation as BatchRemoveEdgesOperation return op.data.edgeSnapshots.every((edge) => Boolean(graph.edgesById[edge.id])) } + case 'batch-add-edges': { + const op = operation as BatchAddEdgesOperation + return op.data.edgeSnapshots.every((edge) => !graph.edgesById[edge.id]) + } case 'add-edge': { const edgeId = operation.data.edgeId return !graph.edgesById[edgeId] } - case 'add-subflow': - case 'remove-subflow': { - const subflowId = operation.data.subflowId - return operation.type === 'remove-subflow' - ? Boolean(graph.blocksById[subflowId]) - : !graph.blocksById[subflowId] - } default: return true } diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index 7973f48587..e32b793447 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -7,10 +7,7 @@ export type OperationType = | 'add-edge' | 'batch-add-edges' | 'batch-remove-edges' - | 'add-subflow' - | 'remove-subflow' | 'batch-move-blocks' - | 'move-subflow' | 'update-parent' | 'batch-toggle-enabled' | 'batch-toggle-handles' @@ -65,21 +62,6 @@ export interface BatchRemoveEdgesOperation extends BaseOperation { } } -export interface AddSubflowOperation extends BaseOperation { - type: 'add-subflow' - data: { - subflowId: string - } -} - -export interface RemoveSubflowOperation extends BaseOperation { - type: 'remove-subflow' - data: { - subflowId: string - subflowSnapshot: BlockState | null - } -} - export interface BatchMoveBlocksOperation extends BaseOperation { type: 'batch-move-blocks' data: { @@ -91,21 +73,6 @@ export interface BatchMoveBlocksOperation extends BaseOperation { } } -export interface MoveSubflowOperation extends BaseOperation { - type: 'move-subflow' - data: { - subflowId: string - before: { - x: number - y: number - } - after: { - x: number - y: number - } - } -} - export interface UpdateParentOperation extends BaseOperation { type: 'update-parent' data: { @@ -169,10 +136,7 @@ export type Operation = | AddEdgeOperation | BatchAddEdgesOperation | BatchRemoveEdgesOperation - | AddSubflowOperation - | RemoveSubflowOperation | BatchMoveBlocksOperation - | MoveSubflowOperation | UpdateParentOperation | BatchToggleEnabledOperation | BatchToggleHandlesOperation diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index d2d5e40876..94c2c43fde 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -79,25 +79,6 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchAddEdgesOperation } - case 'add-subflow': - return { - ...operation, - type: 'remove-subflow', - data: { - subflowId: operation.data.subflowId, - subflowSnapshot: null, - }, - } - - case 'remove-subflow': - return { - ...operation, - type: 'add-subflow', - data: { - subflowId: operation.data.subflowId, - }, - } - case 'batch-move-blocks': { const op = operation as BatchMoveBlocksOperation return { @@ -113,16 +94,6 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchMoveBlocksOperation } - case 'move-subflow': - return { - ...operation, - data: { - subflowId: operation.data.subflowId, - before: operation.data.after, - after: operation.data.before, - }, - } - case 'update-parent': return { ...operation, @@ -256,20 +227,6 @@ export function operationToCollaborativePayload(operation: Operation): { } } - case 'add-subflow': - return { - operation: 'add', - target: 'subflow', - payload: { id: operation.data.subflowId }, - } - - case 'remove-subflow': - return { - operation: 'remove', - target: 'subflow', - payload: { id: operation.data.subflowId }, - } - case 'batch-move-blocks': { const op = operation as BatchMoveBlocksOperation return { @@ -286,17 +243,6 @@ export function operationToCollaborativePayload(operation: Operation): { } } - case 'move-subflow': - return { - operation: 'update-position', - target: 'subflow', - payload: { - id: operation.data.subflowId, - x: operation.data.after.x, - y: operation.data.after.y, - }, - } - case 'update-parent': return { operation: 'update-parent', diff --git a/packages/testing/src/factories/index.ts b/packages/testing/src/factories/index.ts index 45854fd06e..0eb9e6f5ca 100644 --- a/packages/testing/src/factories/index.ts +++ b/packages/testing/src/factories/index.ts @@ -123,18 +123,19 @@ export { type AddEdgeOperation, type BaseOperation, type BatchAddBlocksOperation, + type BatchAddEdgesOperation, type BatchMoveBlocksOperation, type BatchRemoveBlocksOperation, + type BatchRemoveEdgesOperation, createAddBlockEntry, createAddEdgeEntry, + createBatchRemoveEdgesEntry, createMoveBlockEntry, createRemoveBlockEntry, - createRemoveEdgeEntry, createUpdateParentEntry, type Operation, type OperationEntry, type OperationType, - type RemoveEdgeOperation, type UpdateParentOperation, } from './undo-redo.factory' // User/workspace factories diff --git a/packages/testing/src/factories/undo-redo.factory.ts b/packages/testing/src/factories/undo-redo.factory.ts index e55ace2475..ac2432d85b 100644 --- a/packages/testing/src/factories/undo-redo.factory.ts +++ b/packages/testing/src/factories/undo-redo.factory.ts @@ -9,7 +9,8 @@ export type OperationType = | 'batch-add-blocks' | 'batch-remove-blocks' | 'add-edge' - | 'remove-edge' + | 'batch-add-edges' + | 'batch-remove-edges' | 'batch-move-blocks' | 'update-parent' @@ -71,11 +72,19 @@ export interface AddEdgeOperation extends BaseOperation { } /** - * Remove edge operation data. + * Batch add edges operation data. */ -export interface RemoveEdgeOperation extends BaseOperation { - type: 'remove-edge' - data: { edgeId: string; edgeSnapshot: any } +export interface BatchAddEdgesOperation extends BaseOperation { + type: 'batch-add-edges' + data: { edgeSnapshots: any[] } +} + +/** + * Batch remove edges operation data. + */ +export interface BatchRemoveEdgesOperation extends BaseOperation { + type: 'batch-remove-edges' + data: { edgeSnapshots: any[] } } /** @@ -96,7 +105,8 @@ export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation | AddEdgeOperation - | RemoveEdgeOperation + | BatchAddEdgesOperation + | BatchRemoveEdgesOperation | BatchMoveBlocksOperation | UpdateParentOperation @@ -212,10 +222,16 @@ export function createRemoveBlockEntry( /** * Creates a mock add-edge operation entry. */ -export function createAddEdgeEntry(edgeId: string, options: OperationEntryOptions = {}): any { +export function createAddEdgeEntry( + edgeId: string, + edgeSnapshot: any = null, + options: OperationEntryOptions = {} +): any { const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options const timestamp = Date.now() + const snapshot = edgeSnapshot || { id: edgeId, source: 'block-1', target: 'block-2' } + return { id, createdAt, @@ -229,21 +245,20 @@ export function createAddEdgeEntry(edgeId: string, options: OperationEntryOption }, inverse: { id: nanoid(8), - type: 'remove-edge', + type: 'batch-remove-edges', timestamp, workflowId, userId, - data: { edgeId, edgeSnapshot: null }, + data: { edgeSnapshots: [snapshot] }, }, } } /** - * Creates a mock remove-edge operation entry. + * Creates a mock batch-remove-edges operation entry. */ -export function createRemoveEdgeEntry( - edgeId: string, - edgeSnapshot: any = null, +export function createBatchRemoveEdgesEntry( + edgeSnapshots: any[], options: OperationEntryOptions = {} ): any { const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options @@ -254,19 +269,19 @@ export function createRemoveEdgeEntry( createdAt, operation: { id: nanoid(8), - type: 'remove-edge', + type: 'batch-remove-edges', timestamp, workflowId, userId, - data: { edgeId, edgeSnapshot }, + data: { edgeSnapshots }, }, inverse: { id: nanoid(8), - type: 'add-edge', + type: 'batch-add-edges', timestamp, workflowId, userId, - data: { edgeId }, + data: { edgeSnapshots }, }, } } From 3b697077b6c04c1e11dcf7b2764cfabd47cc8155 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 19:33:04 -0800 Subject: [PATCH 19/35] remove unused code --- apps/sim/stores/undo-redo/utils.ts | 145 ----------------------------- 1 file changed, 145 deletions(-) diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index 94c2c43fde..aef24740a4 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -163,148 +163,3 @@ export function createInverseOperation(operation: Operation): Operation { } } } - -export function operationToCollaborativePayload(operation: Operation): { - operation: string - target: string - payload: Record -} { - switch (operation.type) { - case 'batch-add-blocks': { - const op = operation as BatchAddBlocksOperation - return { - operation: 'batch-add-blocks', - target: 'blocks', - payload: { - blocks: op.data.blockSnapshots, - edges: op.data.edgeSnapshots, - loops: {}, - parallels: {}, - subBlockValues: op.data.subBlockValues, - }, - } - } - - case 'batch-remove-blocks': { - const op = operation as BatchRemoveBlocksOperation - return { - operation: 'batch-remove-blocks', - target: 'blocks', - payload: { ids: op.data.blockSnapshots.map((b) => b.id) }, - } - } - - case 'add-edge': - return { - operation: 'add', - target: 'edge', - payload: { id: operation.data.edgeId }, - } - - case 'batch-add-edges': { - const op = operation as BatchAddEdgesOperation - return { - operation: 'batch-add-edges', - target: 'edges', - payload: { - edges: op.data.edgeSnapshots.map((e) => ({ - id: e.id, - source: e.source, - target: e.target, - sourceHandle: e.sourceHandle ?? null, - targetHandle: e.targetHandle ?? null, - })), - }, - } - } - - case 'batch-remove-edges': { - const op = operation as BatchRemoveEdgesOperation - return { - operation: 'batch-remove-edges', - target: 'edges', - payload: { ids: op.data.edgeSnapshots.map((e) => e.id) }, - } - } - - case 'batch-move-blocks': { - const op = operation as BatchMoveBlocksOperation - return { - operation: 'batch-update-positions', - target: 'blocks', - payload: { - moves: op.data.moves.map((m) => ({ - id: m.blockId, - x: m.after.x, - y: m.after.y, - parentId: m.after.parentId, - })), - }, - } - } - - case 'update-parent': - return { - operation: 'update-parent', - target: 'block', - payload: { - id: operation.data.blockId, - parentId: operation.data.newParentId, - x: operation.data.newPosition.x, - y: operation.data.newPosition.y, - }, - } - - case 'apply-diff': - return { - operation: 'apply-diff', - target: 'workflow', - payload: { - diffAnalysis: operation.data.diffAnalysis, - }, - } - - case 'accept-diff': - return { - operation: 'accept-diff', - target: 'workflow', - payload: { - diffAnalysis: operation.data.diffAnalysis, - }, - } - - case 'reject-diff': - return { - operation: 'reject-diff', - target: 'workflow', - payload: { - diffAnalysis: operation.data.diffAnalysis, - }, - } - - case 'batch-toggle-enabled': - return { - operation: 'batch-toggle-enabled', - target: 'blocks', - payload: { - blockIds: operation.data.blockIds, - previousStates: operation.data.previousStates, - }, - } - - case 'batch-toggle-handles': - return { - operation: 'batch-toggle-handles', - target: 'blocks', - payload: { - blockIds: operation.data.blockIds, - previousStates: operation.data.previousStates, - }, - } - - default: { - const exhaustiveCheck: never = operation - throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) - } - } -} From abf46da7b48c44b4ab2b2209d3c4138a0e2af086 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 20:53:34 -0800 Subject: [PATCH 20/35] fixed subflow ops --- .../workflow-edge/workflow-edge.tsx | 23 +- .../[workflowId]/hooks/use-node-utilities.ts | 10 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 237 +++++++++++++++- apps/sim/hooks/use-collaborative-workflow.ts | 66 +++++ apps/sim/hooks/use-undo-redo.ts | 256 +++++++++++++++--- apps/sim/socket/database/operations.ts | 71 +++++ apps/sim/socket/handlers/operations.ts | 29 ++ apps/sim/socket/middleware/permissions.ts | 2 + apps/sim/socket/validation/schemas.ts | 17 ++ apps/sim/stores/undo-redo/store.test.ts | 2 +- apps/sim/stores/undo-redo/store.ts | 9 +- apps/sim/stores/undo-redo/types.ts | 25 +- apps/sim/stores/undo-redo/utils.ts | 30 +- packages/testing/src/factories/index.ts | 5 +- .../src/factories/undo-redo.factory.ts | 104 ++++++- 15 files changed, 782 insertions(+), 104 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index 02d31bd926..2c2cdb00ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -40,10 +40,7 @@ const WorkflowEdgeComponent = ({ }) const isSelected = data?.isSelected ?? false - const isInsideLoop = data?.isInsideLoop ?? false - const parentLoopId = data?.parentLoopId - // Combined store subscription to reduce subscription overhead const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore( useShallow((state) => ({ diffAnalysis: state.diffAnalysis, @@ -124,30 +121,14 @@ const WorkflowEdgeComponent = ({ return ( <> - - {/* Animate dash offset for edge movement effect */} - + {isSelected && (
    ) { childNodes.forEach((node) => { const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id) - // Use block position from store if available (more up-to-date) - const block = blocks[node.id] - const position = block?.position || node.position - maxRight = Math.max(maxRight, position.x + nodeWidth) - maxBottom = Math.max(maxBottom, position.y + nodeHeight) + // Use ReactFlow's node.position which is already in the correct coordinate system + // (relative to parent for child nodes). The store's block.position may be stale + // or still in absolute coordinates during parent updates. + maxRight = Math.max(maxRight, node.position.x + nodeWidth) + maxBottom = Math.max(maxBottom, node.position.y + nodeHeight) }) const width = Math.max( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index ce3a807c4b..c0759f31af 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -447,6 +447,7 @@ const WorkflowContent = React.memo(() => { collaborativeBatchRemoveEdges, collaborativeBatchUpdatePositions, collaborativeUpdateParentId: updateParentId, + collaborativeBatchUpdateParent, collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, collaborativeBatchToggleBlockEnabled, @@ -2437,6 +2438,43 @@ const WorkflowContent = React.memo(() => { previousPositions: multiNodeDragStartRef.current, }) + // Process parent updates for all selected nodes if dropping into a subflow + if (potentialParentId && potentialParentId !== dragStartParentId) { + // Filter out nodes that cannot be moved into subflows + const validNodes = selectedNodes.filter((n) => { + const block = blocks[n.id] + if (!block) return false + // Starter blocks cannot be in containers + if (n.data?.type === 'starter') return false + // Trigger blocks cannot be in containers + if (TriggerUtils.isTriggerBlock(block)) return false + // Subflow nodes (loop/parallel) cannot be nested + if (n.type === 'subflowNode') return false + return true + }) + + if (validNodes.length > 0) { + // Build updates for all valid nodes + const updates = validNodes.map((n) => { + const edgesToRemove = edgesForDisplay.filter( + (e) => e.source === n.id || e.target === n.id + ) + return { + blockId: n.id, + newParentId: potentialParentId, + affectedEdges: edgesToRemove, + } + }) + + collaborativeBatchUpdateParent(updates) + + logger.info('Batch moved nodes into subflow', { + targetParentId: potentialParentId, + nodeCount: validNodes.length, + }) + } + } + // Clear drag start state setDragStartPosition(null) setPotentialParentId(null) @@ -2580,6 +2618,7 @@ const WorkflowContent = React.memo(() => { addNotification, activeWorkflowId, collaborativeBatchUpdatePositions, + collaborativeBatchUpdateParent, ] ) @@ -2594,9 +2633,157 @@ const WorkflowContent = React.memo(() => { requestAnimationFrame(() => setIsSelectionDragActive(false)) }, []) + /** Captures initial positions when selection drag starts (for marquee-selected nodes). */ + const onSelectionDragStart = useCallback( + (_event: React.MouseEvent, nodes: Node[]) => { + // Capture the parent ID of the first node as reference (they should all be in the same context) + if (nodes.length > 0) { + const firstNodeParentId = blocks[nodes[0].id]?.data?.parentId || null + setDragStartParentId(firstNodeParentId) + } + + // Capture all selected nodes' positions for undo/redo + multiNodeDragStartRef.current.clear() + nodes.forEach((n) => { + const block = blocks[n.id] + if (block) { + multiNodeDragStartRef.current.set(n.id, { + x: n.position.x, + y: n.position.y, + parentId: block.data?.parentId, + }) + } + }) + }, + [blocks] + ) + + /** Handles selection drag to detect potential parent containers for batch drops. */ + const onSelectionDrag = useCallback( + (_event: React.MouseEvent, nodes: Node[]) => { + if (nodes.length === 0) return + + // Filter out nodes that can't be placed in containers + const eligibleNodes = nodes.filter((n) => { + if (n.data?.type === 'starter') return false + if (n.type === 'subflowNode') return false + const block = blocks[n.id] + if (block && TriggerUtils.isTriggerBlock(block)) return false + return true + }) + + // If no eligible nodes, clear any potential parent + if (eligibleNodes.length === 0) { + if (potentialParentId) { + clearDragHighlights() + setPotentialParentId(null) + } + return + } + + // Calculate bounding box of all dragged nodes using absolute positions + let minX = Number.POSITIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + eligibleNodes.forEach((node) => { + const absolutePos = getNodeAbsolutePosition(node.id) + const block = blocks[node.id] + const width = BLOCK_DIMENSIONS.FIXED_WIDTH + const height = Math.max( + node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, + BLOCK_DIMENSIONS.MIN_HEIGHT + ) + + minX = Math.min(minX, absolutePos.x) + minY = Math.min(minY, absolutePos.y) + maxX = Math.max(maxX, absolutePos.x + width) + maxY = Math.max(maxY, absolutePos.y + height) + }) + + // Use bounding box for intersection detection + const selectionRect = { left: minX, right: maxX, top: minY, bottom: maxY } + + // Find containers that intersect with the selection bounding box + const allNodes = getNodes() + const intersectingContainers = allNodes + .filter((containerNode) => { + if (containerNode.type !== 'subflowNode') return false + // Skip if any dragged node is this container + if (nodes.some((n) => n.id === containerNode.id)) return false + + const containerAbsolutePos = getNodeAbsolutePosition(containerNode.id) + const containerRect = { + left: containerAbsolutePos.x, + right: + containerAbsolutePos.x + + (containerNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH), + top: containerAbsolutePos.y, + bottom: + containerAbsolutePos.y + + (containerNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), + } + + // Check intersection + return ( + selectionRect.left < containerRect.right && + selectionRect.right > containerRect.left && + selectionRect.top < containerRect.bottom && + selectionRect.bottom > containerRect.top + ) + }) + .map((n) => ({ + container: n, + depth: getNodeDepth(n.id), + size: + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH) * + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT), + })) + + if (intersectingContainers.length > 0) { + // Sort by depth first (deepest first), then by size + const sortedContainers = intersectingContainers.sort((a, b) => { + if (a.depth !== b.depth) return b.depth - a.depth + return a.size - b.size + }) + + const bestMatch = sortedContainers[0] + + if (bestMatch.container.id !== potentialParentId) { + clearDragHighlights() + setPotentialParentId(bestMatch.container.id) + + // Add highlight + const containerElement = document.querySelector(`[data-id="${bestMatch.container.id}"]`) + if (containerElement) { + if ((bestMatch.container.data as SubflowNodeData)?.kind === 'loop') { + containerElement.classList.add('loop-node-drag-over') + } else if ((bestMatch.container.data as SubflowNodeData)?.kind === 'parallel') { + containerElement.classList.add('parallel-node-drag-over') + } + document.body.style.cursor = 'copy' + } + } + } else if (potentialParentId) { + clearDragHighlights() + setPotentialParentId(null) + } + }, + [ + blocks, + getNodes, + potentialParentId, + getNodeAbsolutePosition, + getNodeDepth, + clearDragHighlights, + ] + ) + const onSelectionDragStop = useCallback( (_event: React.MouseEvent, nodes: any[]) => { requestAnimationFrame(() => setIsSelectionDragActive(false)) + clearDragHighlights() if (nodes.length === 0) return const allNodes = getNodes() @@ -2604,9 +2791,55 @@ const WorkflowContent = React.memo(() => { collaborativeBatchUpdatePositions(positionUpdates, { previousPositions: multiNodeDragStartRef.current, }) + + // Process parent updates if dropping into a subflow + if (potentialParentId && potentialParentId !== dragStartParentId) { + // Filter out nodes that cannot be moved into subflows + const validNodes = nodes.filter((n: Node) => { + const block = blocks[n.id] + if (!block) return false + if (n.data?.type === 'starter') return false + if (TriggerUtils.isTriggerBlock(block)) return false + if (n.type === 'subflowNode') return false + return true + }) + + if (validNodes.length > 0) { + const updates = validNodes.map((n: Node) => { + const edgesToRemove = edgesForDisplay.filter( + (e) => e.source === n.id || e.target === n.id + ) + return { + blockId: n.id, + newParentId: potentialParentId, + affectedEdges: edgesToRemove, + } + }) + + collaborativeBatchUpdateParent(updates) + + logger.info('Batch moved selection into subflow', { + targetParentId: potentialParentId, + nodeCount: validNodes.length, + }) + } + } + + // Clear drag state + setDragStartPosition(null) + setPotentialParentId(null) multiNodeDragStartRef.current.clear() }, - [blocks, getNodes, collaborativeBatchUpdatePositions] + [ + blocks, + getNodes, + collaborativeBatchUpdatePositions, + collaborativeBatchUpdateParent, + potentialParentId, + dragStartParentId, + edgesForDisplay, + clearDragHighlights, + ] ) const onPaneClick = useCallback(() => { @@ -2830,6 +3063,8 @@ const WorkflowContent = React.memo(() => { className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`} onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined} onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined} + onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined} + onSelectionDrag={effectivePermissions.canEdit ? onSelectionDrag : undefined} onSelectionDragStop={effectivePermissions.canEdit ? onSelectionDragStop : undefined} onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined} snapToGrid={snapToGrid} diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 9825ccea06..a81ecaf670 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -924,6 +924,71 @@ export function useCollaborativeWorkflow() { [executeQueuedOperation, workflowStore] ) + const collaborativeBatchUpdateParent = useCallback( + ( + updates: Array<{ + blockId: string + newParentId: string | null + affectedEdges: Edge[] + }> + ) => { + if (!isInActiveRoom()) { + logger.debug('Skipping batch update parent - not in active workflow') + return + } + + if (updates.length === 0) return + + const batchUpdates = updates.map((u) => { + const block = workflowStore.blocks[u.blockId] + const oldParentId = block?.data?.parentId + const oldPosition = block?.position || { x: 0, y: 0 } + const newPosition = oldPosition + + return { + blockId: u.blockId, + oldParentId, + newParentId: u.newParentId || undefined, + oldPosition, + newPosition, + affectedEdges: u.affectedEdges, + } + }) + + for (const update of updates) { + if (update.affectedEdges.length > 0) { + update.affectedEdges.forEach((e) => workflowStore.removeEdge(e.id)) + } + if (update.newParentId) { + workflowStore.updateParentId(update.blockId, update.newParentId, 'parent') + } + } + + undoRedo.recordBatchUpdateParent(batchUpdates) + + const operationId = crypto.randomUUID() + addToQueue({ + id: operationId, + operation: { + operation: 'batch-update-parent', + target: 'blocks', + payload: { + updates: batchUpdates.map((u) => ({ + id: u.blockId, + parentId: u.newParentId || '', + position: u.newPosition, + })), + }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + logger.debug('Batch updated parent for blocks', { updateCount: updates.length }) + }, + [isInActiveRoom, workflowStore, undoRedo, addToQueue, activeWorkflowId, session?.user?.id] + ) + const collaborativeToggleBlockAdvancedMode = useCallback( (id: string) => { const currentBlock = workflowStore.blocks[id] @@ -1662,6 +1727,7 @@ export function useCollaborativeWorkflow() { collaborativeUpdateBlockName, collaborativeBatchToggleBlockEnabled, collaborativeUpdateParentId, + collaborativeBatchUpdateParent, collaborativeToggleBlockAdvancedMode, collaborativeToggleBlockTriggerMode, collaborativeBatchToggleBlockHandles, diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 4660ffeee2..304df71b6a 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -12,8 +12,8 @@ import { type BatchRemoveEdgesOperation, type BatchToggleEnabledOperation, type BatchToggleHandlesOperation, + type BatchUpdateParentOperation, createOperationEntry, - type Operation, runWithUndoRedoRecordingSuspended, type UpdateParentOperation, useUndoRedoStore, @@ -132,14 +132,18 @@ export function useUndoRedo() { if (!activeWorkflowId) return const edgeSnapshot = workflowStore.edges.find((e) => e.id === edgeId) + if (!edgeSnapshot) { + logger.warn('Edge not found when recording add edge', { edgeId }) + return + } - const operation: Operation = { + const operation: BatchAddEdgesOperation = { id: crypto.randomUUID(), - type: 'add-edge', + type: 'batch-add-edges', timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { edgeId }, + data: { edgeSnapshots: [edgeSnapshot] }, } const inverse: BatchRemoveEdgesOperation = { @@ -148,9 +152,7 @@ export function useUndoRedo() { timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { - edgeSnapshots: edgeSnapshot ? [edgeSnapshot] : [], - }, + data: { edgeSnapshots: [edgeSnapshot] }, } const entry = createOperationEntry(operation, inverse) @@ -297,6 +299,57 @@ export function useUndoRedo() { [activeWorkflowId, userId, undoRedoStore] ) + const recordBatchUpdateParent = useCallback( + ( + updates: Array<{ + blockId: string + oldParentId?: string + newParentId?: string + oldPosition: { x: number; y: number } + newPosition: { x: number; y: number } + affectedEdges?: Edge[] + }> + ) => { + if (!activeWorkflowId || updates.length === 0) return + + const operation: BatchUpdateParentOperation = { + id: crypto.randomUUID(), + type: 'batch-update-parent', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { updates }, + } + + const inverse: BatchUpdateParentOperation = { + id: crypto.randomUUID(), + type: 'batch-update-parent', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { + updates: updates.map((u) => ({ + blockId: u.blockId, + oldParentId: u.newParentId, + newParentId: u.oldParentId, + oldPosition: u.newPosition, + newPosition: u.oldPosition, + affectedEdges: u.affectedEdges, + })), + }, + } + + const entry = createOperationEntry(operation, inverse) + undoRedoStore.push(activeWorkflowId, userId, entry) + + logger.debug('Recorded batch update parent', { + updateCount: updates.length, + workflowId: activeWorkflowId, + }) + }, + [activeWorkflowId, userId, undoRedoStore] + ) + const recordBatchToggleEnabled = useCallback( (blockIds: string[], previousStates: Record) => { if (!activeWorkflowId || blockIds.length === 0) return @@ -493,7 +546,7 @@ export function useUndoRedo() { break } case 'batch-remove-edges': { - // Undo add-edge: inverse is batch-remove-edges, so remove the edges + // Undo batch-add-edges: inverse is batch-remove-edges, so remove the edges const batchRemoveInverse = entry.inverse as BatchRemoveEdgesOperation const { edgeSnapshots } = batchRemoveInverse.data @@ -514,7 +567,7 @@ export function useUndoRedo() { }) edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) } - logger.debug('Undid add-edge', { edgeCount: edgesToRemove.length }) + logger.debug('Undid batch-add-edges', { edgeCount: edgesToRemove.length }) break } case 'batch-add-edges': { @@ -575,12 +628,10 @@ export function useUndoRedo() { break } case 'update-parent': { - // Undo parent update means reverting to the old parent and position const updateOp = entry.inverse as UpdateParentOperation const { blockId, newParentId, newPosition, affectedEdges } = updateOp.data if (workflowStore.blocks[blockId]) { - // If we're moving back INTO a subflow, restore edges first if (newParentId && affectedEdges && affectedEdges.length > 0) { const edgesToAdd = affectedEdges.filter( (e) => !workflowStore.edges.find((edge) => edge.id === e.id) @@ -600,7 +651,6 @@ export function useUndoRedo() { } } - // Send position update to server addToQueue({ id: crypto.randomUUID(), operation: { @@ -665,6 +715,85 @@ export function useUndoRedo() { } break } + case 'batch-update-parent': { + const batchUpdateOp = entry.inverse as BatchUpdateParentOperation + const { updates } = batchUpdateOp.data + + const validUpdates = updates.filter((u) => workflowStore.blocks[u.blockId]) + if (validUpdates.length === 0) { + logger.debug('Undo batch-update-parent skipped; no blocks exist') + break + } + + // Process each update + for (const update of validUpdates) { + const { blockId, newParentId, newPosition, affectedEdges } = update + + // Moving OUT of subflow (undoing insert) → restore edges first + if (!newParentId && affectedEdges && affectedEdges.length > 0) { + const edgesToAdd = affectedEdges.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + if (edgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } + } + + // Moving INTO subflow (undoing removal) → remove edges first + if (newParentId && affectedEdges && affectedEdges.length > 0) { + affectedEdges.forEach((edge) => { + if (workflowStore.edges.find((e) => e.id === edge.id)) { + workflowStore.removeEdge(edge.id) + } + }) + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'batch-remove-edges', + target: 'edges', + payload: { edgeIds: affectedEdges.map((e) => e.id) }, + }, + workflowId: activeWorkflowId, + userId, + }) + } + + // Update position and parent locally + workflowStore.updateBlockPosition(blockId, newPosition) + workflowStore.updateParentId(blockId, newParentId || '', 'parent') + } + + // Send batch update to server + addToQueue({ + id: opId, + operation: { + operation: 'batch-update-parent', + target: 'blocks', + payload: { + updates: validUpdates.map((u) => ({ + id: u.blockId, + parentId: u.newParentId || '', + position: u.newPosition, + })), + }, + }, + workflowId: activeWorkflowId, + userId, + }) + + logger.debug('Undid batch-update-parent', { updateCount: validUpdates.length }) + break + } case 'batch-toggle-enabled': { const toggleOp = entry.inverse as BatchToggleEnabledOperation const { blockIds, previousStates } = toggleOp.data @@ -1001,29 +1130,6 @@ export function useUndoRedo() { existingBlockIds.forEach((id) => workflowStore.removeBlock(id)) break } - case 'add-edge': { - const inv = entry.inverse as BatchRemoveEdgesOperation - const edgeSnapshots = inv.data.edgeSnapshots - - const edgesToAdd = edgeSnapshots.filter( - (e) => !workflowStore.edges.find((edge) => edge.id === e.id) - ) - - if (edgesToAdd.length > 0) { - addToQueue({ - id: opId, - operation: { - operation: 'batch-add-edges', - target: 'edges', - payload: { edges: edgesToAdd }, - }, - workflowId: activeWorkflowId, - userId, - }) - edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) - } - break - } case 'batch-remove-edges': { // Redo batch-remove-edges: remove all edges again const batchRemoveOp = entry.operation as BatchRemoveEdgesOperation @@ -1199,6 +1305,85 @@ export function useUndoRedo() { } break } + case 'batch-update-parent': { + const batchUpdateOp = entry.operation as BatchUpdateParentOperation + const { updates } = batchUpdateOp.data + + const validUpdates = updates.filter((u) => workflowStore.blocks[u.blockId]) + if (validUpdates.length === 0) { + logger.debug('Redo batch-update-parent skipped; no blocks exist') + break + } + + // Process each update + for (const update of validUpdates) { + const { blockId, newParentId, newPosition, affectedEdges } = update + + // Moving INTO subflow (redoing insert) → remove edges first + if (newParentId && affectedEdges && affectedEdges.length > 0) { + affectedEdges.forEach((edge) => { + if (workflowStore.edges.find((e) => e.id === edge.id)) { + workflowStore.removeEdge(edge.id) + } + }) + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'batch-remove-edges', + target: 'edges', + payload: { edgeIds: affectedEdges.map((e) => e.id) }, + }, + workflowId: activeWorkflowId, + userId, + }) + } + + // Update position and parent locally + workflowStore.updateBlockPosition(blockId, newPosition) + workflowStore.updateParentId(blockId, newParentId || '', 'parent') + + // Moving OUT of subflow (redoing removal) → restore edges after + if (!newParentId && affectedEdges && affectedEdges.length > 0) { + const edgesToAdd = affectedEdges.filter( + (e) => !workflowStore.edges.find((edge) => edge.id === e.id) + ) + if (edgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: 'batch-add-edges', + target: 'edges', + payload: { edges: edgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + } + } + } + + // Send batch update to server + addToQueue({ + id: opId, + operation: { + operation: 'batch-update-parent', + target: 'blocks', + payload: { + updates: validUpdates.map((u) => ({ + id: u.blockId, + parentId: u.newParentId || '', + position: u.newPosition, + })), + }, + }, + workflowId: activeWorkflowId, + userId, + }) + + logger.debug('Redid batch-update-parent', { updateCount: validUpdates.length }) + break + } case 'batch-toggle-enabled': { const toggleOp = entry.operation as BatchToggleEnabledOperation const { blockIds, previousStates } = toggleOp.data @@ -1573,6 +1758,7 @@ export function useUndoRedo() { recordBatchRemoveEdges, recordBatchMoveBlocks, recordUpdateParent, + recordBatchUpdateParent, recordBatchToggleEnabled, recordBatchToggleHandles, recordApplyDiff, diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index cdd4ff1872..6e07e15044 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -749,6 +749,77 @@ async function handleBlocksOperationTx( break } + case 'batch-update-parent': { + const { updates } = payload + if (!Array.isArray(updates) || updates.length === 0) { + return + } + + logger.info(`Batch updating parent for ${updates.length} blocks in workflow ${workflowId}`) + + for (const update of updates) { + const { id, parentId, position } = update + if (!id) continue + + // Fetch current parent to update subflow node lists + const [existing] = await tx + .select({ + id: workflowBlocks.id, + parentId: sql`${workflowBlocks.data}->>'parentId'`, + }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (!existing) { + logger.warn(`Block ${id} not found for batch-update-parent`) + continue + } + + const isRemovingFromParent = !parentId + + // Get current data to update + const [currentBlock] = await tx + .select({ data: workflowBlocks.data }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + const currentData = currentBlock?.data || {} + + // Update data with parentId and extent + const updatedData = isRemovingFromParent + ? {} // Clear data entirely when removing from parent + : { + ...currentData, + ...(parentId ? { parentId, extent: 'parent' } : {}), + } + + // Update position and data + await tx + .update(workflowBlocks) + .set({ + positionX: position?.x ?? currentBlock?.data?.positionX, + positionY: position?.y ?? currentBlock?.data?.positionY, + data: updatedData, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) + + // If the block now has a parent, update the new parent's subflow node list + if (parentId) { + await updateSubflowNodeList(tx, workflowId, parentId) + } + // If the block had a previous parent, update that parent's node list as well + if (existing?.parentId && existing.parentId !== parentId) { + await updateSubflowNodeList(tx, workflowId, existing.parentId) + } + } + + logger.debug(`Batch updated parent for ${updates.length} blocks`) + break + } + default: throw new Error(`Unsupported blocks operation: ${operation}`) } diff --git a/apps/sim/socket/handlers/operations.ts b/apps/sim/socket/handlers/operations.ts index eaeeab3062..28d563b99d 100644 --- a/apps/sim/socket/handlers/operations.ts +++ b/apps/sim/socket/handlers/operations.ts @@ -404,6 +404,35 @@ export function setupOperationsHandlers( return } + if (target === 'blocks' && operation === 'batch-update-parent') { + 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 === 'edges' && operation === 'batch-add-edges') { await persistWorkflowOperation(workflowId, { operation, diff --git a/apps/sim/socket/middleware/permissions.ts b/apps/sim/socket/middleware/permissions.ts index 3a43010b99..5772142e20 100644 --- a/apps/sim/socket/middleware/permissions.ts +++ b/apps/sim/socket/middleware/permissions.ts @@ -20,6 +20,7 @@ const ROLE_PERMISSIONS: Record = { 'batch-remove-edges', 'batch-toggle-enabled', 'batch-toggle-handles', + 'batch-update-parent', 'update-name', 'toggle-enabled', 'update-parent', @@ -41,6 +42,7 @@ const ROLE_PERMISSIONS: Record = { 'batch-remove-edges', 'batch-toggle-enabled', 'batch-toggle-handles', + 'batch-update-parent', 'update-name', 'toggle-enabled', 'update-parent', diff --git a/apps/sim/socket/validation/schemas.ts b/apps/sim/socket/validation/schemas.ts index e1bd5f520f..13266f7808 100644 --- a/apps/sim/socket/validation/schemas.ts +++ b/apps/sim/socket/validation/schemas.ts @@ -197,6 +197,22 @@ export const BatchToggleHandlesSchema = z.object({ operationId: z.string().optional(), }) +export const BatchUpdateParentSchema = z.object({ + operation: z.literal('batch-update-parent'), + target: z.literal('blocks'), + payload: z.object({ + updates: z.array( + z.object({ + id: z.string(), + parentId: z.string(), + position: PositionSchema, + }) + ), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + export const WorkflowOperationSchema = z.union([ BlockOperationSchema, BatchPositionUpdateSchema, @@ -204,6 +220,7 @@ export const WorkflowOperationSchema = z.union([ BatchRemoveBlocksSchema, BatchToggleEnabledSchema, BatchToggleHandlesSchema, + BatchUpdateParentSchema, EdgeOperationSchema, BatchAddEdgesSchema, BatchRemoveEdgesSchema, diff --git a/apps/sim/stores/undo-redo/store.test.ts b/apps/sim/stores/undo-redo/store.test.ts index 3f8a5a3c07..add8625961 100644 --- a/apps/sim/stores/undo-redo/store.test.ts +++ b/apps/sim/stores/undo-redo/store.test.ts @@ -596,7 +596,7 @@ describe('useUndoRedoStore', () => { expect(getStackSizes(workflowId, userId).undoSize).toBe(2) const entry = undo(workflowId, userId) - expect(entry?.operation.type).toBe('add-edge') + expect(entry?.operation.type).toBe('batch-add-edges') expect(getStackSizes(workflowId, userId).redoSize).toBe(1) redo(workflowId, userId) diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts index 3aabbd8558..881e26ee80 100644 --- a/apps/sim/stores/undo-redo/store.ts +++ b/apps/sim/stores/undo-redo/store.ts @@ -8,6 +8,7 @@ import type { BatchMoveBlocksOperation, BatchRemoveBlocksOperation, BatchRemoveEdgesOperation, + BatchUpdateParentOperation, Operation, OperationEntry, UndoRedoState, @@ -101,6 +102,10 @@ function isOperationApplicable( const blockId = operation.data.blockId return Boolean(graph.blocksById[blockId]) } + case 'batch-update-parent': { + const op = operation as BatchUpdateParentOperation + return op.data.updates.every((u) => Boolean(graph.blocksById[u.blockId])) + } case 'batch-remove-edges': { const op = operation as BatchRemoveEdgesOperation return op.data.edgeSnapshots.every((edge) => Boolean(graph.edgesById[edge.id])) @@ -109,10 +114,6 @@ function isOperationApplicable( const op = operation as BatchAddEdgesOperation return op.data.edgeSnapshots.every((edge) => !graph.edgesById[edge.id]) } - case 'add-edge': { - const edgeId = operation.data.edgeId - return !graph.edgesById[edgeId] - } default: return true } diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index e32b793447..53147d7e7c 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -4,11 +4,11 @@ import type { BlockState } from '@/stores/workflows/workflow/types' export type OperationType = | 'batch-add-blocks' | 'batch-remove-blocks' - | 'add-edge' | 'batch-add-edges' | 'batch-remove-edges' | 'batch-move-blocks' | 'update-parent' + | 'batch-update-parent' | 'batch-toggle-enabled' | 'batch-toggle-handles' | 'apply-diff' @@ -41,13 +41,6 @@ export interface BatchRemoveBlocksOperation extends BaseOperation { } } -export interface AddEdgeOperation extends BaseOperation { - type: 'add-edge' - data: { - edgeId: string - } -} - export interface BatchAddEdgesOperation extends BaseOperation { type: 'batch-add-edges' data: { @@ -85,6 +78,20 @@ export interface UpdateParentOperation extends BaseOperation { } } +export interface BatchUpdateParentOperation extends BaseOperation { + type: 'batch-update-parent' + data: { + updates: Array<{ + blockId: string + oldParentId?: string + newParentId?: string + oldPosition: { x: number; y: number } + newPosition: { x: number; y: number } + affectedEdges?: Edge[] + }> + } +} + export interface BatchToggleEnabledOperation extends BaseOperation { type: 'batch-toggle-enabled' data: { @@ -133,11 +140,11 @@ export interface RejectDiffOperation extends BaseOperation { export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation - | AddEdgeOperation | BatchAddEdgesOperation | BatchRemoveEdgesOperation | BatchMoveBlocksOperation | UpdateParentOperation + | BatchUpdateParentOperation | BatchToggleEnabledOperation | BatchToggleHandlesOperation | ApplyDiffOperation diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index aef24740a4..d56604be2d 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -4,6 +4,7 @@ import type { BatchMoveBlocksOperation, BatchRemoveBlocksOperation, BatchRemoveEdgesOperation, + BatchUpdateParentOperation, Operation, OperationEntry, } from '@/stores/undo-redo/types' @@ -45,18 +46,6 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchAddBlocksOperation } - case 'add-edge': - // Note: add-edge only stores edgeId. The full edge snapshot is stored - // in the inverse operation when recording. This function can't create - // a complete inverse without the snapshot. - return { - ...operation, - type: 'batch-remove-edges', - data: { - edgeSnapshots: [], - }, - } as BatchRemoveEdgesOperation - case 'batch-add-edges': { const op = operation as BatchAddEdgesOperation return { @@ -107,6 +96,23 @@ export function createInverseOperation(operation: Operation): Operation { }, } + case 'batch-update-parent': { + const op = operation as BatchUpdateParentOperation + return { + ...operation, + data: { + updates: op.data.updates.map((u) => ({ + blockId: u.blockId, + oldParentId: u.newParentId, + newParentId: u.oldParentId, + oldPosition: u.newPosition, + newPosition: u.oldPosition, + affectedEdges: u.affectedEdges, + })), + }, + } as BatchUpdateParentOperation + } + case 'apply-diff': return { ...operation, diff --git a/packages/testing/src/factories/index.ts b/packages/testing/src/factories/index.ts index 0eb9e6f5ca..2fafe98625 100644 --- a/packages/testing/src/factories/index.ts +++ b/packages/testing/src/factories/index.ts @@ -120,16 +120,17 @@ export { } from './serialized-block.factory' // Undo/redo operation factories export { - type AddEdgeOperation, type BaseOperation, type BatchAddBlocksOperation, type BatchAddEdgesOperation, type BatchMoveBlocksOperation, type BatchRemoveBlocksOperation, type BatchRemoveEdgesOperation, + type BatchUpdateParentOperation, createAddBlockEntry, createAddEdgeEntry, createBatchRemoveEdgesEntry, + createBatchUpdateParentEntry, createMoveBlockEntry, createRemoveBlockEntry, createUpdateParentEntry, @@ -138,7 +139,6 @@ export { type OperationType, type UpdateParentOperation, } from './undo-redo.factory' -// User/workspace factories export { createUser, createUserWithWorkspace, @@ -148,7 +148,6 @@ export { type WorkflowObjectFactoryOptions, type WorkspaceFactoryOptions, } from './user.factory' -// Workflow factories export { createBranchingWorkflow, createLinearWorkflow, diff --git a/packages/testing/src/factories/undo-redo.factory.ts b/packages/testing/src/factories/undo-redo.factory.ts index ac2432d85b..86c26ef927 100644 --- a/packages/testing/src/factories/undo-redo.factory.ts +++ b/packages/testing/src/factories/undo-redo.factory.ts @@ -8,11 +8,11 @@ import { nanoid } from 'nanoid' export type OperationType = | 'batch-add-blocks' | 'batch-remove-blocks' - | 'add-edge' | 'batch-add-edges' | 'batch-remove-edges' | 'batch-move-blocks' | 'update-parent' + | 'batch-update-parent' /** * Base operation interface. @@ -63,14 +63,6 @@ export interface BatchRemoveBlocksOperation extends BaseOperation { } } -/** - * Add edge operation data. - */ -export interface AddEdgeOperation extends BaseOperation { - type: 'add-edge' - data: { edgeId: string } -} - /** * Batch add edges operation data. */ @@ -101,14 +93,28 @@ export interface UpdateParentOperation extends BaseOperation { } } +export interface BatchUpdateParentOperation extends BaseOperation { + type: 'batch-update-parent' + data: { + updates: Array<{ + blockId: string + oldParentId?: string + newParentId?: string + oldPosition: { x: number; y: number } + newPosition: { x: number; y: number } + affectedEdges?: any[] + }> + } +} + export type Operation = | BatchAddBlocksOperation | BatchRemoveBlocksOperation - | AddEdgeOperation | BatchAddEdgesOperation | BatchRemoveEdgesOperation | BatchMoveBlocksOperation | UpdateParentOperation + | BatchUpdateParentOperation /** * Operation entry with forward and inverse operations. @@ -220,7 +226,7 @@ export function createRemoveBlockEntry( } /** - * Creates a mock add-edge operation entry. + * Creates a mock batch-add-edges operation entry for a single edge. */ export function createAddEdgeEntry( edgeId: string, @@ -237,11 +243,11 @@ export function createAddEdgeEntry( createdAt, operation: { id: nanoid(8), - type: 'add-edge', + type: 'batch-add-edges', timestamp, workflowId, userId, - data: { edgeId }, + data: { edgeSnapshots: [snapshot] }, }, inverse: { id: nanoid(8), @@ -378,3 +384,75 @@ export function createUpdateParentEntry( }, } } + +interface BatchUpdateParentOptions extends OperationEntryOptions { + updates?: Array<{ + blockId: string + oldParentId?: string + newParentId?: string + oldPosition?: { x: number; y: number } + newPosition?: { x: number; y: number } + affectedEdges?: any[] + }> +} + +/** + * Creates a mock batch-update-parent operation entry. + */ +export function createBatchUpdateParentEntry(options: BatchUpdateParentOptions = {}): any { + const { + id = nanoid(8), + workflowId = 'wf-1', + userId = 'user-1', + createdAt = Date.now(), + updates = [ + { + blockId: 'block-1', + oldParentId: undefined, + newParentId: 'loop-1', + oldPosition: { x: 0, y: 0 }, + newPosition: { x: 50, y: 50 }, + }, + ], + } = options + const timestamp = Date.now() + + const processedUpdates = updates.map((u) => ({ + blockId: u.blockId, + oldParentId: u.oldParentId, + newParentId: u.newParentId, + oldPosition: u.oldPosition || { x: 0, y: 0 }, + newPosition: u.newPosition || { x: 50, y: 50 }, + affectedEdges: u.affectedEdges, + })) + + return { + id, + createdAt, + operation: { + id: nanoid(8), + type: 'batch-update-parent', + timestamp, + workflowId, + userId, + data: { updates: processedUpdates }, + }, + inverse: { + id: nanoid(8), + type: 'batch-update-parent', + timestamp, + workflowId, + userId, + data: { + updates: processedUpdates.map((u) => ({ + blockId: u.blockId, + oldParentId: u.newParentId, + newParentId: u.oldParentId, + oldPosition: u.newPosition, + newPosition: u.oldPosition, + affectedEdges: u.affectedEdges, + })), + }, + }, + } +} From 24b918aff07b1396ebc3765af6fc50789daaba2b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 8 Jan 2026 21:34:36 -0800 Subject: [PATCH 21/35] fix subflow resizing --- .../[workflowId]/hooks/use-node-utilities.ts | 79 +++++++++--------- .../[workspaceId]/w/[workflowId]/workflow.tsx | 80 ++++++++++++++++++- apps/sim/hooks/use-collaborative-workflow.ts | 5 +- 3 files changed, 119 insertions(+), 45 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index 77fb9cd7c9..ffa148d881 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { useReactFlow } from 'reactflow' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { getBlock } from '@/blocks/registry' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('NodeUtilities') @@ -208,28 +209,30 @@ export function useNodeUtilities(blocks: Record) { * to the content area bounds (after header and padding). * @param nodeId ID of the node being repositioned * @param newParentId ID of the new parent + * @param skipClamping If true, returns raw relative position without clamping to container bounds * @returns Relative position coordinates {x, y} within the parent */ const calculateRelativePosition = useCallback( - (nodeId: string, newParentId: string): { x: number; y: number } => { + (nodeId: string, newParentId: string, skipClamping?: boolean): { x: number; y: number } => { const nodeAbsPos = getNodeAbsolutePosition(nodeId) const parentAbsPos = getNodeAbsolutePosition(newParentId) - const parentNode = getNodes().find((n) => n.id === newParentId) - // Calculate raw relative position (relative to parent origin) const rawPosition = { x: nodeAbsPos.x - parentAbsPos.x, y: nodeAbsPos.y - parentAbsPos.y, } - // Get container and block dimensions + if (skipClamping) { + return rawPosition + } + + const parentNode = getNodes().find((n) => n.id === newParentId) const containerDimensions = { width: parentNode?.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, height: parentNode?.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, } const blockDimensions = getBlockDimensions(nodeId) - // Clamp position to keep block inside content area return clampPositionToContainer(rawPosition, containerDimensions, blockDimensions) }, [getNodeAbsolutePosition, getNodes, getBlockDimensions] @@ -298,12 +301,12 @@ export function useNodeUtilities(blocks: Record) { */ const calculateLoopDimensions = useCallback( (nodeId: string): { width: number; height: number } => { - // Check both React Flow's node.parentId AND blocks store's data.parentId - // This ensures we catch children even if React Flow hasn't re-rendered yet - const childNodes = getNodes().filter( - (node) => node.parentId === nodeId || blocks[node.id]?.data?.parentId === nodeId + const currentBlocks = useWorkflowStore.getState().blocks + const childBlockIds = Object.keys(currentBlocks).filter( + (id) => currentBlocks[id]?.data?.parentId === nodeId ) - if (childNodes.length === 0) { + + if (childBlockIds.length === 0) { return { width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, @@ -313,30 +316,28 @@ export function useNodeUtilities(blocks: Record) { let maxRight = 0 let maxBottom = 0 - childNodes.forEach((node) => { - const { width: nodeWidth, height: nodeHeight } = getBlockDimensions(node.id) - // Use ReactFlow's node.position which is already in the correct coordinate system - // (relative to parent for child nodes). The store's block.position may be stale - // or still in absolute coordinates during parent updates. - maxRight = Math.max(maxRight, node.position.x + nodeWidth) - maxBottom = Math.max(maxBottom, node.position.y + nodeHeight) - }) + for (const childId of childBlockIds) { + const child = currentBlocks[childId] + if (!child?.position) continue + + const { width: childWidth, height: childHeight } = getBlockDimensions(childId) + + maxRight = Math.max(maxRight, child.position.x + childWidth) + maxBottom = Math.max(maxBottom, child.position.y + childHeight) + } const width = Math.max( CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING ) const height = Math.max( CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - CONTAINER_DIMENSIONS.HEADER_HEIGHT + - CONTAINER_DIMENSIONS.TOP_PADDING + - maxBottom + - CONTAINER_DIMENSIONS.BOTTOM_PADDING + maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING ) return { width, height } }, - [getNodes, getBlockDimensions, blocks] + [getBlockDimensions] ) /** @@ -345,29 +346,27 @@ export function useNodeUtilities(blocks: Record) { */ const resizeLoopNodes = useCallback( (updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void) => { - const containerNodes = getNodes() - .filter((node) => node.type && isContainerType(node.type)) - .map((node) => ({ - ...node, - depth: getNodeDepth(node.id), + const currentBlocks = useWorkflowStore.getState().blocks + const containerBlocks = Object.entries(currentBlocks) + .filter(([, block]) => block?.type && isContainerType(block.type)) + .map(([id, block]) => ({ + id, + block, + depth: getNodeDepth(id), })) - // Sort by depth descending - process innermost containers first - // so their dimensions are correct when outer containers calculate sizes .sort((a, b) => b.depth - a.depth) - containerNodes.forEach((node) => { - const dimensions = calculateLoopDimensions(node.id) - // Get current dimensions from the blocks store rather than React Flow's potentially stale state - const currentWidth = blocks[node.id]?.data?.width - const currentHeight = blocks[node.id]?.data?.height + for (const { id, block } of containerBlocks) { + const dimensions = calculateLoopDimensions(id) + const currentWidth = block?.data?.width + const currentHeight = block?.data?.height - // Only update if dimensions actually changed to avoid unnecessary re-renders if (dimensions.width !== currentWidth || dimensions.height !== currentHeight) { - updateNodeDimensions(node.id, dimensions) + updateNodeDimensions(id, dimensions) } - }) + } }, - [getNodes, isContainerType, getNodeDepth, calculateLoopDimensions, blocks] + [isContainerType, getNodeDepth, calculateLoopDimensions] ) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index c0759f31af..3dad0354e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -303,6 +303,7 @@ const WorkflowContent = React.memo(() => { const { getNodeDepth, getNodeAbsolutePosition, + calculateRelativePosition, isPointInLoopNode, resizeLoopNodes, updateNodeParent: updateNodeParentUtil, @@ -2454,20 +2455,54 @@ const WorkflowContent = React.memo(() => { }) if (validNodes.length > 0) { - // Build updates for all valid nodes - const updates = validNodes.map((n) => { + const rawUpdates = validNodes.map((n) => { const edgesToRemove = edgesForDisplay.filter( (e) => e.source === n.id || e.target === n.id ) + const newPosition = calculateRelativePosition(n.id, potentialParentId, true) return { blockId: n.id, newParentId: potentialParentId, + newPosition, affectedEdges: edgesToRemove, } }) + const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x)) + const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y)) + + const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING + const targetMinY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING + + const shiftX = minX < targetMinX ? targetMinX - minX : 0 + const shiftY = minY < targetMinY ? targetMinY - minY : 0 + + const updates = rawUpdates.map((u) => ({ + ...u, + newPosition: { + x: u.newPosition.x + shiftX, + y: u.newPosition.y + shiftY, + }, + })) + collaborativeBatchUpdateParent(updates) + setDisplayNodes((nodes) => + nodes.map((node) => { + const update = updates.find((u) => u.blockId === node.id) + if (update) { + return { + ...node, + position: update.newPosition, + parentId: update.newParentId, + } + } + return node + }) + ) + + resizeLoopNodesWrapper() + logger.info('Batch moved nodes into subflow', { targetParentId: potentialParentId, nodeCount: validNodes.length, @@ -2613,6 +2648,8 @@ const WorkflowContent = React.memo(() => { edgesForDisplay, removeEdgesForNode, getNodeAbsolutePosition, + calculateRelativePosition, + resizeLoopNodesWrapper, getDragStartPosition, setDragStartPosition, addNotification, @@ -2805,19 +2842,54 @@ const WorkflowContent = React.memo(() => { }) if (validNodes.length > 0) { - const updates = validNodes.map((n: Node) => { + const rawUpdates = validNodes.map((n: Node) => { const edgesToRemove = edgesForDisplay.filter( (e) => e.source === n.id || e.target === n.id ) + const newPosition = calculateRelativePosition(n.id, potentialParentId, true) return { blockId: n.id, newParentId: potentialParentId, + newPosition, affectedEdges: edgesToRemove, } }) + const minX = Math.min(...rawUpdates.map((u) => u.newPosition.x)) + const minY = Math.min(...rawUpdates.map((u) => u.newPosition.y)) + + const targetMinX = CONTAINER_DIMENSIONS.LEFT_PADDING + const targetMinY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING + + const shiftX = minX < targetMinX ? targetMinX - minX : 0 + const shiftY = minY < targetMinY ? targetMinY - minY : 0 + + const updates = rawUpdates.map((u) => ({ + ...u, + newPosition: { + x: u.newPosition.x + shiftX, + y: u.newPosition.y + shiftY, + }, + })) + collaborativeBatchUpdateParent(updates) + setDisplayNodes((nodes) => + nodes.map((node) => { + const update = updates.find((u) => u.blockId === node.id) + if (update) { + return { + ...node, + position: update.newPosition, + parentId: update.newParentId, + } + } + return node + }) + ) + + resizeLoopNodesWrapper() + logger.info('Batch moved selection into subflow', { targetParentId: potentialParentId, nodeCount: validNodes.length, @@ -2835,6 +2907,8 @@ const WorkflowContent = React.memo(() => { getNodes, collaborativeBatchUpdatePositions, collaborativeBatchUpdateParent, + calculateRelativePosition, + resizeLoopNodesWrapper, potentialParentId, dragStartParentId, edgesForDisplay, diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index a81ecaf670..2bd9ce819a 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -929,6 +929,7 @@ export function useCollaborativeWorkflow() { updates: Array<{ blockId: string newParentId: string | null + newPosition: { x: number; y: number } affectedEdges: Edge[] }> ) => { @@ -943,14 +944,13 @@ export function useCollaborativeWorkflow() { const block = workflowStore.blocks[u.blockId] const oldParentId = block?.data?.parentId const oldPosition = block?.position || { x: 0, y: 0 } - const newPosition = oldPosition return { blockId: u.blockId, oldParentId, newParentId: u.newParentId || undefined, oldPosition, - newPosition, + newPosition: u.newPosition, affectedEdges: u.affectedEdges, } }) @@ -959,6 +959,7 @@ export function useCollaborativeWorkflow() { if (update.affectedEdges.length > 0) { update.affectedEdges.forEach((e) => workflowStore.removeEdge(e.id)) } + workflowStore.updateBlockPosition(update.blockId, update.newPosition) if (update.newParentId) { workflowStore.updateParentId(update.blockId, update.newParentId, 'parent') } From 60e25fdc7a7ec1b2d6b3a87c4d6da674d3499a66 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 21:15:16 -0800 Subject: [PATCH 22/35] keep edges on subflow actions intact --- apps/sim/app/_styles/globals.css | 4 +- .../w/[workflowId]/hooks/index.ts | 1 + .../utils/workflow-canvas-helpers.ts | 42 +++++- .../[workspaceId]/w/[workflowId]/workflow.tsx | 140 +++++++++++++----- 4 files changed, 149 insertions(+), 38 deletions(-) diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 4123df565c..eaac62a570 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -51,9 +51,11 @@ border: 1px solid var(--brand-secondary) !important; } -.react-flow__nodesselection-rect { +.react-flow__nodesselection-rect, +.react-flow__nodesselection { background: transparent !important; border: none !important; + pointer-events: none !important; } /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts index 65bd3d4e49..3af268aa37 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts @@ -1,6 +1,7 @@ export { clearDragHighlights, computeClampedPositionUpdates, + computeParentUpdateEntries, getClampedPositionForNode, isInEditableElement, selectNodesDeferred, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts index 634aedb326..a0f2a57722 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -1,4 +1,4 @@ -import type { Node } from 'reactflow' +import type { Edge, Node } from 'reactflow' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' @@ -139,3 +139,43 @@ export function computeClampedPositionUpdates( position: getClampedPositionForNode(node.id, node.position, blocks, allNodes), })) } + +interface ParentUpdateEntry { + blockId: string + newParentId: string + affectedEdges: Edge[] +} + +/** + * Computes parent update entries for nodes being moved into a subflow. + * Only includes "boundary edges" - edges that cross the selection boundary + * (one end inside selection, one end outside). Edges between nodes in the + * selection are preserved. + */ +export function computeParentUpdateEntries( + validNodes: Node[], + allEdges: Edge[], + targetParentId: string +): ParentUpdateEntry[] { + const movingNodeIds = new Set(validNodes.map((n) => n.id)) + + // Find edges that cross the boundary (one end inside selection, one end outside) + // Edges between nodes in the selection should stay intact + const boundaryEdges = allEdges.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + // Only remove if exactly one end is in the selection (crosses boundary) + return sourceInSelection !== targetInSelection + }) + + // Build updates for all valid nodes + return validNodes.map((n) => { + // Only include boundary edges connected to this specific node + const edgesForThisNode = boundaryEdges.filter((e) => e.source === n.id || e.target === n.id) + return { + blockId: n.id, + newParentId: targetParentId, + affectedEdges: edgesForThisNode, + } + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 3dad0354e3..a3f813fc0a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -2286,9 +2286,6 @@ const WorkflowContent = React.memo(() => { // Only consider container nodes that aren't the dragged node if (n.type !== 'subflowNode' || n.id === node.id) return false - // Skip if this container is already the parent of the node being dragged - if (n.id === currentParentId) return false - // Get the container's absolute position const containerAbsolutePos = getNodeAbsolutePosition(n.id) @@ -2439,32 +2436,55 @@ const WorkflowContent = React.memo(() => { previousPositions: multiNodeDragStartRef.current, }) - // Process parent updates for all selected nodes if dropping into a subflow - if (potentialParentId && potentialParentId !== dragStartParentId) { - // Filter out nodes that cannot be moved into subflows - const validNodes = selectedNodes.filter((n) => { - const block = blocks[n.id] - if (!block) return false - // Starter blocks cannot be in containers - if (n.data?.type === 'starter') return false - // Trigger blocks cannot be in containers - if (TriggerUtils.isTriggerBlock(block)) return false - // Subflow nodes (loop/parallel) cannot be nested - if (n.type === 'subflowNode') return false + // Process parent updates for nodes whose parent is changing + // Check each node individually - don't rely on dragStartParentId since + // multi-node selections can contain nodes from different parents + const selectedNodeIds = new Set(selectedNodes.map((n) => n.id)) + const nodesNeedingParentUpdate = selectedNodes.filter((n) => { + const block = blocks[n.id] + if (!block) return false + const currentParent = block.data?.parentId || null + // Skip if the node's parent is also being moved (keep children with their parent) + if (currentParent && selectedNodeIds.has(currentParent)) return false + // Node needs update if current parent !== target parent + return currentParent !== potentialParentId + }) + + if (nodesNeedingParentUpdate.length > 0) { + // Filter out nodes that cannot be moved into subflows (when target is a subflow) + const validNodes = nodesNeedingParentUpdate.filter((n) => { + // These restrictions only apply when moving INTO a subflow + if (potentialParentId) { + if (n.data?.type === 'starter') return false + const block = blocks[n.id] + if (block && TriggerUtils.isTriggerBlock(block)) return false + if (n.type === 'subflowNode') return false + } return true }) if (validNodes.length > 0) { + // Use boundary edge logic - only remove edges crossing the boundary + const movingNodeIds = new Set(validNodes.map((n) => n.id)) + const boundaryEdges = edgesForDisplay.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + return sourceInSelection !== targetInSelection + }) + const rawUpdates = validNodes.map((n) => { - const edgesToRemove = edgesForDisplay.filter( + const edgesForThisNode = boundaryEdges.filter( (e) => e.source === n.id || e.target === n.id ) - const newPosition = calculateRelativePosition(n.id, potentialParentId, true) + // Use relative position when moving into a container, absolute position when moving to root + const newPosition = potentialParentId + ? calculateRelativePosition(n.id, potentialParentId, true) + : getNodeAbsolutePosition(n.id) return { blockId: n.id, newParentId: potentialParentId, newPosition, - affectedEdges: edgesToRemove, + affectedEdges: edgesForThisNode, } }) @@ -2494,7 +2514,7 @@ const WorkflowContent = React.memo(() => { return { ...node, position: update.newPosition, - parentId: update.newParentId, + parentId: update.newParentId ?? undefined, } } return node @@ -2503,7 +2523,7 @@ const WorkflowContent = React.memo(() => { resizeLoopNodesWrapper() - logger.info('Batch moved nodes into subflow', { + logger.info('Batch moved nodes to new parent', { targetParentId: potentialParentId, nodeCount: validNodes.length, }) @@ -2631,6 +2651,30 @@ const WorkflowContent = React.memo(() => { edgesToAdd.forEach((edge) => addEdge(edge)) window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } })) + } else if (!potentialParentId && dragStartParentId) { + // Moving OUT of a subflow to canvas + // Remove edges connected to this node since it's leaving its parent + const edgesToRemove = edgesForDisplay.filter( + (e) => e.source === node.id || e.target === node.id + ) + + if (edgesToRemove.length > 0) { + removeEdgesForNode(node.id, edgesToRemove) + + logger.info('Removed edges when moving node out of subflow', { + blockId: node.id, + sourceParentId: dragStartParentId, + edgeCount: edgesToRemove.length, + }) + } + + // Clear the parent relationship + updateNodeParent(node.id, null, edgesToRemove) + + logger.info('Moved node out of subflow', { + blockId: node.id, + sourceParentId: dragStartParentId, + }) } // Reset state @@ -2829,29 +2873,55 @@ const WorkflowContent = React.memo(() => { previousPositions: multiNodeDragStartRef.current, }) - // Process parent updates if dropping into a subflow - if (potentialParentId && potentialParentId !== dragStartParentId) { - // Filter out nodes that cannot be moved into subflows - const validNodes = nodes.filter((n: Node) => { - const block = blocks[n.id] - if (!block) return false - if (n.data?.type === 'starter') return false - if (TriggerUtils.isTriggerBlock(block)) return false - if (n.type === 'subflowNode') return false + // Process parent updates for nodes whose parent is changing + // Check each node individually - don't rely on dragStartParentId since + // multi-node selections can contain nodes from different parents + const selectedNodeIds = new Set(nodes.map((n: Node) => n.id)) + const nodesNeedingParentUpdate = nodes.filter((n: Node) => { + const block = blocks[n.id] + if (!block) return false + const currentParent = block.data?.parentId || null + // Skip if the node's parent is also being moved (keep children with their parent) + if (currentParent && selectedNodeIds.has(currentParent)) return false + // Node needs update if current parent !== target parent + return currentParent !== potentialParentId + }) + + if (nodesNeedingParentUpdate.length > 0) { + // Filter out nodes that cannot be moved into subflows (when target is a subflow) + const validNodes = nodesNeedingParentUpdate.filter((n: Node) => { + // These restrictions only apply when moving INTO a subflow + if (potentialParentId) { + if (n.data?.type === 'starter') return false + const block = blocks[n.id] + if (block && TriggerUtils.isTriggerBlock(block)) return false + if (n.type === 'subflowNode') return false + } return true }) if (validNodes.length > 0) { + // Use boundary edge logic - only remove edges crossing the boundary + const movingNodeIds = new Set(validNodes.map((n: Node) => n.id)) + const boundaryEdges = edgesForDisplay.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + return sourceInSelection !== targetInSelection + }) + const rawUpdates = validNodes.map((n: Node) => { - const edgesToRemove = edgesForDisplay.filter( + const edgesForThisNode = boundaryEdges.filter( (e) => e.source === n.id || e.target === n.id ) - const newPosition = calculateRelativePosition(n.id, potentialParentId, true) + // Use relative position when moving into a container, absolute position when moving to root + const newPosition = potentialParentId + ? calculateRelativePosition(n.id, potentialParentId, true) + : getNodeAbsolutePosition(n.id) return { blockId: n.id, newParentId: potentialParentId, newPosition, - affectedEdges: edgesToRemove, + affectedEdges: edgesForThisNode, } }) @@ -2881,7 +2951,7 @@ const WorkflowContent = React.memo(() => { return { ...node, position: update.newPosition, - parentId: update.newParentId, + parentId: update.newParentId ?? undefined, } } return node @@ -2890,7 +2960,7 @@ const WorkflowContent = React.memo(() => { resizeLoopNodesWrapper() - logger.info('Batch moved selection into subflow', { + logger.info('Batch moved selection to new parent', { targetParentId: potentialParentId, nodeCount: validNodes.length, }) @@ -2910,7 +2980,6 @@ const WorkflowContent = React.memo(() => { calculateRelativePosition, resizeLoopNodesWrapper, potentialParentId, - dragStartParentId, edgesForDisplay, clearDragHighlights, ] @@ -2995,7 +3064,6 @@ const WorkflowContent = React.memo(() => { /** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */ const edgesWithSelection = useMemo(() => { - // Build node lookup map once - O(n) instead of O(n) per edge const nodeMap = new Map(displayNodes.map((n) => [n.id, n])) return edgesForDisplay.map((edge) => { From 3f37b5c91f88f8acee843a8e6aef4c851440b614 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 8 Jan 2026 21:56:44 -0800 Subject: [PATCH 23/35] fixed copy from inside subflow --- apps/sim/stores/workflows/utils.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 2eb2c4618a..ae61833385 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -541,17 +541,16 @@ export function regenerateBlockIds( const newNormalizedName = normalizeName(newName) nameMap.set(oldNormalizedName, newNormalizedName) - const isNested = !!block.data?.parentId + // Always apply position offset and clear parentId since we paste to canvas level const newBlock: BlockState = { ...block, id: newId, name: newName, - position: isNested - ? block.position - : { - x: block.position.x + positionOffset.x, - y: block.position.y + positionOffset.y, - }, + position: { + x: block.position.x + positionOffset.x, + y: block.position.y + positionOffset.y, + }, + data: block.data ? { ...block.data, parentId: undefined } : block.data, } newBlocks[newId] = newBlock From 7022b4ca1867374a2a6880e31b4971c215515462 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 9 Jan 2026 11:40:34 -0800 Subject: [PATCH 24/35] types improvement, preview fixes --- apps/sim/app/api/v1/admin/types.ts | 34 +- .../workflows/[id]/variables/route.test.ts | 25 +- .../app/api/workflows/[id]/variables/route.ts | 42 +- .../execution-snapshot/components/index.ts | 1 + .../components/snapshot-context-menu.tsx | 97 ++++ .../execution-snapshot/execution-snapshot.tsx | 126 ++++- .../components/trace-spans/trace-spans.tsx | 172 ++++++- .../log-row-context-menu.tsx | 2 +- .../w/[workflowId]/components/chat/chat.tsx | 11 +- .../components/usage-limit-actions.tsx | 4 +- .../credential-selector.tsx | 4 +- .../components/tool-input/tool-input.tsx | 30 +- .../panel/hooks/use-usage-limits.ts | 5 +- .../components/log-row-context-menu.tsx | 24 +- .../components/terminal/terminal.tsx | 160 ++----- .../components/block-details-sidebar.tsx | 437 ++++++++++++++++-- .../w/components/preview/preview.tsx | 12 + .../settings-modal/settings-modal.tsx | 2 +- .../w/hooks/use-export-workflow.ts | 12 +- .../w/hooks/use-export-workspace.ts | 27 +- .../w/hooks/use-import-workflow.ts | 45 +- .../w/hooks/use-import-workspace.ts | 45 +- apps/sim/executor/constants.ts | 6 +- apps/sim/hooks/queries/subscription.ts | 15 +- apps/sim/hooks/use-code-viewer.ts | 155 +++++++ apps/sim/hooks/use-forwarded-ref.ts | 25 - apps/sim/hooks/use-subscription-state.ts | 217 --------- .../sim/lib/logs/execution/logging-factory.ts | 14 +- .../logs/execution/snapshot/service.test.ts | 108 +++++ .../lib/logs/execution/snapshot/service.ts | 4 + apps/sim/lib/workflows/autolayout/types.ts | 27 +- .../sim/lib/workflows/blocks/block-outputs.ts | 78 ++-- apps/sim/lib/workflows/comparison/compare.ts | 13 +- .../workflows/comparison/normalize.test.ts | 51 +- .../sim/lib/workflows/comparison/normalize.ts | 113 ++++- .../credentials/credential-extractor.ts | 78 +++- apps/sim/lib/workflows/diff/diff-engine.ts | 12 +- .../workflows/executor/execute-workflow.ts | 13 +- .../lib/workflows/executor/execution-core.ts | 38 +- .../executor/human-in-the-loop-manager.ts | 47 +- .../lib/workflows/operations/import-export.ts | 9 +- apps/sim/lib/workflows/persistence/utils.ts | 70 ++- .../workflows/sanitization/json-sanitizer.ts | 88 +++- .../lib/workflows/sanitization/validation.ts | 76 +-- apps/sim/lib/workflows/streaming/streaming.ts | 40 +- .../training/compute-edit-sequence.ts | 105 ++--- .../lib/workflows/triggers/trigger-utils.ts | 26 +- .../workflows/variables/variable-manager.ts | 21 +- apps/sim/scripts/export-workflow.ts | 15 +- apps/sim/stores/panel/variables/types.ts | 2 +- apps/sim/stores/workflows/subblock/types.ts | 26 +- apps/sim/stores/workflows/workflow/types.ts | 18 +- 52 files changed, 1905 insertions(+), 922 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components/snapshot-context-menu.tsx create mode 100644 apps/sim/hooks/use-code-viewer.ts delete mode 100644 apps/sim/hooks/use-forwarded-ref.ts delete mode 100644 apps/sim/hooks/use-subscription-state.ts diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index 4c3916810c..fbc12ae7ec 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -243,7 +243,7 @@ export interface WorkflowExportState { color?: string exportedAt?: string } - variables?: WorkflowVariable[] + variables?: Record } export interface WorkflowExportPayload { @@ -317,36 +317,44 @@ export interface WorkspaceImportResponse { // ============================================================================= /** - * Parse workflow variables from database JSON format to array format. - * Handles both array and Record formats. + * Parse workflow variables from database JSON format to Record format. + * Handles both legacy Array and current Record formats. */ export function parseWorkflowVariables( dbVariables: DbWorkflow['variables'] -): WorkflowVariable[] | undefined { +): Record | undefined { if (!dbVariables) return undefined try { const varsObj = typeof dbVariables === 'string' ? JSON.parse(dbVariables) : dbVariables + // Handle legacy Array format by converting to Record if (Array.isArray(varsObj)) { - return varsObj.map((v) => ({ - id: v.id, - name: v.name, - type: v.type, - value: v.value, - })) + const result: Record = {} + for (const v of varsObj) { + result[v.id] = { + id: v.id, + name: v.name, + type: v.type, + value: v.value, + } + } + return result } + // Already Record format - normalize and return if (typeof varsObj === 'object' && varsObj !== null) { - return Object.values(varsObj).map((v: unknown) => { + const result: Record = {} + for (const [key, v] of Object.entries(varsObj)) { const variable = v as { id: string; name: string; type: VariableType; value: unknown } - return { + result[key] = { id: variable.id, name: variable.name, type: variable.type, value: variable.value, } - }) + } + return result } } catch { // pass diff --git a/apps/sim/app/api/workflows/[id]/variables/route.test.ts b/apps/sim/app/api/workflows/[id]/variables/route.test.ts index f7e105d3c9..b2485fa408 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.test.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.test.ts @@ -207,9 +207,15 @@ describe('Workflow Variables API Route', () => { update: { results: [{}] }, }) - const variables = [ - { id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' }, - ] + const variables = { + 'var-1': { + id: 'var-1', + workflowId: 'workflow-123', + name: 'test', + type: 'string', + value: 'hello', + }, + } const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', { method: 'POST', @@ -242,9 +248,15 @@ describe('Workflow Variables API Route', () => { isWorkspaceOwner: false, }) - const variables = [ - { id: 'var-1', workflowId: 'workflow-123', name: 'test', type: 'string', value: 'hello' }, - ] + const variables = { + 'var-1': { + id: 'var-1', + workflowId: 'workflow-123', + name: 'test', + type: 'string', + value: 'hello', + }, + } const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', { method: 'POST', @@ -277,7 +289,6 @@ describe('Workflow Variables API Route', () => { isWorkspaceOwner: false, }) - // Invalid data - missing required fields const invalidData = { variables: [{ name: 'test' }] } const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables', { diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index ec7d5d486f..f107f31748 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -11,16 +11,22 @@ import type { Variable } from '@/stores/panel/variables/types' const logger = createLogger('WorkflowVariablesAPI') +const VariableSchema = z.object({ + id: z.string(), + workflowId: z.string(), + name: z.string(), + type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']), + value: z.union([ + z.string(), + z.number(), + z.boolean(), + z.record(z.unknown()), + z.array(z.unknown()), + ]), +}) + const VariablesSchema = z.object({ - variables: z.array( - z.object({ - id: z.string(), - workflowId: z.string(), - name: z.string(), - type: z.enum(['string', 'number', 'boolean', 'object', 'array', 'plain']), - value: z.union([z.string(), z.number(), z.boolean(), z.record(z.any()), z.array(z.any())]), - }) - ), + variables: z.record(z.string(), VariableSchema), }) export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { @@ -60,21 +66,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: try { const { variables } = VariablesSchema.parse(body) - // Format variables for storage - const variablesRecord: Record = {} - variables.forEach((variable) => { - variablesRecord[variable.id] = variable - }) - - // Replace variables completely with the incoming ones + // Variables are already in Record format - use directly // The frontend is the source of truth for what variables should exist - const updatedVariables = variablesRecord - - // Update workflow with variables await db .update(workflow) .set({ - variables: updatedVariables, + variables, updatedAt: new Date(), }) .where(eq(workflow.id, workflowId)) @@ -148,8 +145,9 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: headers, } ) - } catch (error: any) { + } catch (error) { logger.error(`[${requestId}] Workflow variables fetch error`, error) - return NextResponse.json({ error: error.message }, { status: 500 }) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + return NextResponse.json({ error: errorMessage }, { status: 500 }) } } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components/index.ts new file mode 100644 index 0000000000..577f184a69 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components/index.ts @@ -0,0 +1 @@ +export { SnapshotContextMenu } from './snapshot-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components/snapshot-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components/snapshot-context-menu.tsx new file mode 100644 index 0000000000..155c2a6eb0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components/snapshot-context-menu.tsx @@ -0,0 +1,97 @@ +'use client' + +import type { RefObject } from 'react' +import { createPortal } from 'react-dom' +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverDivider, + PopoverItem, +} from '@/components/emcn' + +interface SnapshotContextMenuProps { + isOpen: boolean + position: { x: number; y: number } + menuRef: RefObject + onClose: () => void + onCopy: () => void + onSearch?: () => void + wrapText?: boolean + onToggleWrap?: () => void + /** When true, only shows Copy option (for subblock values) */ + copyOnly?: boolean +} + +/** + * Context menu for execution snapshot sidebar. + * Provides copy, search, and display options. + * Uses createPortal to render outside any transformed containers (like modals). + */ +export function SnapshotContextMenu({ + isOpen, + position, + menuRef, + onClose, + onCopy, + onSearch, + wrapText, + onToggleWrap, + copyOnly = false, +}: SnapshotContextMenuProps) { + if (typeof document === 'undefined') return null + + return createPortal( + + + + { + onCopy() + onClose() + }} + > + Copy + + + {!copyOnly && onSearch && ( + <> + + { + onSearch() + onClose() + }} + > + Search + + + )} + + {!copyOnly && onToggleWrap && ( + <> + + + Wrap Text + + + )} + + , + document.body + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx index bfda572622..b17b01351c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx @@ -1,8 +1,18 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AlertCircle, Loader2 } from 'lucide-react' -import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn' +import { createPortal } from 'react-dom' +import { + Modal, + ModalBody, + ModalContent, + ModalHeader, + Popover, + PopoverAnchor, + PopoverContent, + PopoverItem, +} from '@/components/emcn' import { redactApiKeys } from '@/lib/core/security/redaction' import { cn } from '@/lib/core/utils/cn' import { @@ -61,6 +71,45 @@ export function ExecutionSnapshot({ const { data, isLoading, error } = useExecutionSnapshot(executionId) const [pinnedBlockId, setPinnedBlockId] = useState(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) + const [contextMenuBlockId, setContextMenuBlockId] = useState(null) + const menuRef = useRef(null) + + const closeMenu = useCallback(() => { + setIsMenuOpen(false) + setContextMenuBlockId(null) + }, []) + + const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setContextMenuBlockId(null) + setMenuPosition({ x: e.clientX, y: e.clientY }) + setIsMenuOpen(true) + }, []) + + const handleNodeContextMenu = useCallback( + (blockId: string, mousePosition: { x: number; y: number }) => { + setContextMenuBlockId(blockId) + setMenuPosition(mousePosition) + setIsMenuOpen(true) + }, + [] + ) + + const handleCopyExecutionId = useCallback(() => { + navigator.clipboard.writeText(executionId) + closeMenu() + }, [executionId, closeMenu]) + + const handleOpenDetails = useCallback(() => { + if (contextMenuBlockId) { + setPinnedBlockId(contextMenuBlockId) + } + closeMenu() + }, [contextMenuBlockId, closeMenu]) + const blockExecutions = useMemo(() => { if (!traceSpans || !Array.isArray(traceSpans)) return {} @@ -173,7 +222,7 @@ export function ExecutionSnapshot({ className )} > -
    +
    { setPinnedBlockId((prev) => (prev === blockId ? null : blockId)) }} + onNodeContextMenu={handleNodeContextMenu} cursorStyle='pointer' executedBlocks={blockExecutions} /> @@ -193,32 +243,72 @@ export function ExecutionSnapshot({ executionData={blockExecutions[pinnedBlockId]} allBlockExecutions={blockExecutions} workflowBlocks={workflowState.blocks} + workflowVariables={workflowState.variables} isExecutionMode + onClose={() => setPinnedBlockId(null)} /> )}
    ) } + const canvasContextMenu = + typeof document !== 'undefined' + ? createPortal( + + + + {contextMenuBlockId && ( + Open Details + )} + Copy Execution ID + + , + document.body + ) + : null + if (isModal) { return ( - { - if (!open) { - setPinnedBlockId(null) - onClose() - } - }} - > - - Workflow State + <> + { + if (!open) { + setPinnedBlockId(null) + onClose() + } + }} + > + + Workflow State - {renderContent()} - - + {renderContent()} + + + {canvasContextMenu} + ) } - return renderContent() + return ( + <> + {renderContent()} + {canvasContextMenu} + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx index 46a7444ccc..fa16d94240 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-spans/trace-spans.tsx @@ -1,13 +1,27 @@ 'use client' import type React from 'react' -import { memo, useCallback, useMemo, useState } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import clsx from 'clsx' -import { ChevronDown, Code } from '@/components/emcn' +import { ArrowDown, ArrowUp, X } from 'lucide-react' +import { createPortal } from 'react-dom' +import { + Button, + ChevronDown, + Code, + Input, + Popover, + PopoverAnchor, + PopoverContent, + PopoverDivider, + PopoverItem, +} from '@/components/emcn' import { WorkflowIcon } from '@/components/icons' +import { cn } from '@/lib/core/utils/cn' import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config' import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config' import { getBlock, getBlockByToolName } from '@/blocks' +import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import type { TraceSpan } from '@/stores/logs/filters/types' interface TraceSpansProps { @@ -370,7 +384,7 @@ function SpanContent({ } /** - * Renders input/output section with collapsible content + * Renders input/output section with collapsible content, context menu, and search */ function InputOutputSection({ label, @@ -391,14 +405,63 @@ function InputOutputSection({ }) { const sectionKey = `${spanId}-${sectionType}` const isExpanded = expandedSections.has(sectionKey) + const contentRef = useRef(null) + const menuRef = useRef(null) + + // Context menu state + const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }) + + // Code viewer features + const { + wrapText, + toggleWrapText, + isSearchActive, + searchQuery, + setSearchQuery, + matchCount, + currentMatchIndex, + activateSearch, + closeSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef, + } = useCodeViewerFeatures({ contentRef }) const jsonString = useMemo(() => { if (!data) return '' return JSON.stringify(data, null, 2) }, [data]) + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setContextMenuPosition({ x: e.clientX, y: e.clientY }) + setIsContextMenuOpen(true) + }, []) + + const closeContextMenu = useCallback(() => { + setIsContextMenuOpen(false) + }, []) + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(jsonString) + closeContextMenu() + }, [jsonString, closeContextMenu]) + + const handleSearch = useCallback(() => { + activateSearch() + closeContextMenu() + }, [activateSearch, closeContextMenu]) + + const handleToggleWrap = useCallback(() => { + toggleWrapText() + closeContextMenu() + }, [toggleWrapText, closeContextMenu]) + return ( -
    +
    onToggle(sectionKey)} @@ -433,12 +496,101 @@ function InputOutputSection({ />
    {isExpanded && ( - + <> +
    + +
    + + {/* Search Overlay */} + {isSearchActive && ( +
    e.stopPropagation()} + > + setSearchQuery(e.target.value)} + placeholder='Search...' + className='mr-[2px] h-[23px] w-[94px] text-[12px]' + /> + 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' + )} + > + {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} + + + + +
    + )} + + {/* Context Menu - rendered in portal to avoid transform/overflow clipping */} + {typeof document !== 'undefined' && + createPortal( + + + + Copy + + Search + + Wrap Text + + + , + document.body + )} + )}
    ) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx index f25a71732f..56c8cdab00 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-row-context-menu/log-row-context-menu.tsx @@ -87,7 +87,7 @@ export function LogRowContextMenu({ onClose() }} > - Open Preview + Open Snapshot {/* Filter actions */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index d46eab0f2d..ea9a2bec5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -726,7 +726,9 @@ export function Chat() { (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() - handleSendMessage() + if (!isStreaming && !isExecuting) { + handleSendMessage() + } } else if (e.key === 'ArrowUp') { e.preventDefault() if (promptHistory.length > 0) { @@ -749,7 +751,7 @@ export function Chat() { } } }, - [handleSendMessage, promptHistory, historyIndex] + [handleSendMessage, promptHistory, historyIndex, isStreaming, isExecuting] ) /** @@ -1061,7 +1063,7 @@ export function Chat() { onKeyDown={handleKeyPress} placeholder={isDragOver ? 'Drop files here...' : 'Type a message...'} className='w-full border-0 bg-transparent pr-[56px] pl-[4px] shadow-none focus-visible:ring-0 focus-visible:ring-offset-0' - disabled={!activeWorkflowId || isExecuting} + disabled={!activeWorkflowId} /> {/* Buttons positioned absolutely on the right */} @@ -1091,7 +1093,8 @@ export function Chat() { disabled={ (!chatMessage.trim() && chatFiles.length === 0) || !activeWorkflowId || - isExecuting + isExecuting || + isStreaming } className={cn( 'h-[22px] w-[22px] rounded-full p-0 transition-colors', diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/usage-limit-actions.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/usage-limit-actions.tsx index 0683694588..a4cc01b5fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/usage-limit-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/usage-limit-actions.tsx @@ -4,10 +4,12 @@ import { useState } from 'react' import { Loader2 } from 'lucide-react' import { Button } from '@/components/emcn' import { canEditUsageLimit } from '@/lib/billing/subscriptions/utils' +import { getEnv, isTruthy } from '@/lib/core/config/env' import { isHosted } from '@/lib/core/config/feature-flags' import { useSubscriptionData, useUpdateUsageLimit } from '@/hooks/queries/subscription' import { useCopilotStore } from '@/stores/panel/copilot/store' +const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) const LIMIT_INCREMENTS = [0, 50, 100] as const function roundUpToNearest50(value: number): number { @@ -15,7 +17,7 @@ function roundUpToNearest50(value: number): number { } export function UsageLimitActions() { - const { data: subscriptionData } = useSubscriptionData() + const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled }) const updateUsageLimitMutation = useUpdateUsageLimit() const subscription = subscriptionData?.data diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx index 4a4f112e23..c7f307b13b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/credential-selector.tsx @@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger' import { ExternalLink, Users } from 'lucide-react' import { Button, Combobox } from '@/components/emcn/components' import { getSubscriptionStatus } from '@/lib/billing/client' +import { getEnv, isTruthy } from '@/lib/core/config/env' import { getPollingProviderFromOAuth } from '@/lib/credential-sets/providers' import { getCanonicalScopesForProvider, @@ -26,6 +27,7 @@ import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' const logger = createLogger('CredentialSelector') +const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) interface CredentialSelectorProps { blockId: string @@ -54,7 +56,7 @@ export function CredentialSelector({ const supportsCredentialSets = subBlock.supportsCredentialSets || false const { data: organizationsData } = useOrganizations() - const { data: subscriptionData } = useSubscriptionData() + const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled }) const activeOrganization = organizationsData?.activeOrganization const subscriptionStatus = getSubscriptionStatus(subscriptionData?.data) const hasTeamPlan = subscriptionStatus.isTeam || subscriptionStatus.isEnterprise diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 6823e303b4..ac23aa5df7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -902,7 +902,22 @@ export function ToolInput({ const [draggedIndex, setDraggedIndex] = useState(null) const [dragOverIndex, setDragOverIndex] = useState(null) const [usageControlPopoverIndex, setUsageControlPopoverIndex] = useState(null) - const { data: customTools = [] } = useCustomTools(workspaceId) + + const value = isPreview ? previewValue : storeValue + + const selectedTools: StoredTool[] = + Array.isArray(value) && + value.length > 0 && + value[0] !== null && + typeof value[0]?.type === 'string' + ? (value as StoredTool[]) + : [] + + const hasReferenceOnlyCustomTools = selectedTools.some( + (tool) => tool.type === 'custom-tool' && tool.customToolId && !tool.code + ) + const shouldFetchCustomTools = !isPreview || hasReferenceOnlyCustomTools + const { data: customTools = [] } = useCustomTools(shouldFetchCustomTools ? workspaceId : '') const { mcpTools, @@ -918,24 +933,15 @@ export function ToolInput({ const mcpDataLoading = mcpLoading || mcpServersLoading const hasRefreshedRef = useRef(false) - const value = isPreview ? previewValue : storeValue - - const selectedTools: StoredTool[] = - Array.isArray(value) && - value.length > 0 && - value[0] !== null && - typeof value[0]?.type === 'string' - ? (value as StoredTool[]) - : [] - const hasMcpTools = selectedTools.some((tool) => tool.type === 'mcp') useEffect(() => { + if (isPreview) return if (hasMcpTools && !hasRefreshedRef.current) { hasRefreshedRef.current = true forceRefreshMcpTools(workspaceId) } - }, [hasMcpTools, forceRefreshMcpTools, workspaceId]) + }, [hasMcpTools, forceRefreshMcpTools, workspaceId, isPreview]) /** * Returns issue info for an MCP tool. diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts index 2262449303..4bf8846668 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-usage-limits.ts @@ -1,5 +1,8 @@ +import { getEnv, isTruthy } from '@/lib/core/config/env' import { useSubscriptionData } from '@/hooks/queries/subscription' +const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED')) + /** * Simplified hook that uses React Query for usage limits. * Provides usage exceeded status from existing subscription data. @@ -12,7 +15,7 @@ export function useUsageLimits(options?: { }) { // For now, we only support user context via React Query // Organization context should use useOrganizationBilling directly - const { data: subscriptionData, isLoading } = useSubscriptionData() + const { data: subscriptionData, isLoading } = useSubscriptionData({ enabled: isBillingEnabled }) const usageExceeded = subscriptionData?.data?.usage?.isExceeded || false diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx index 7c009f5058..06c654dcf1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/components/log-row-context-menu.tsx @@ -31,6 +31,7 @@ interface LogRowContextMenuProps { onFilterByBlock: (blockId: string) => void onFilterByStatus: (status: 'error' | 'info') => void onFilterByRunId: (runId: string) => void + onCopyRunId: (runId: string) => void onClearFilters: () => void onClearConsole: () => void hasActiveFilters: boolean @@ -50,6 +51,7 @@ export function LogRowContextMenu({ onFilterByBlock, onFilterByStatus, onFilterByRunId, + onCopyRunId, onClearFilters, onClearConsole, hasActiveFilters, @@ -79,18 +81,18 @@ export function LogRowContextMenu({ }} /> - {/* Clear filters at top when active */} - {hasActiveFilters && ( + {/* Copy actions */} + {entry && hasRunId && ( <> { - onClearFilters() + onCopyRunId(entry.executionId!) onClose() }} > - Clear All Filters + Copy Run ID - {entry && } + )} @@ -129,6 +131,18 @@ export function LogRowContextMenu({ )} + {/* Clear filters */} + {hasActiveFilters && ( + { + onClearFilters() + onClose() + }} + > + Clear All Filters + + )} + {/* Destructive action */} {(entry || hasActiveFilters) && } (null) const outputContentRef = useRef(null) + const { + isSearchActive: isOutputSearchActive, + searchQuery: outputSearchQuery, + setSearchQuery: setOutputSearchQuery, + matchCount, + currentMatchIndex, + activateSearch: activateOutputSearch, + closeSearch: closeOutputSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef: outputSearchInputRef, + } = useCodeViewerFeatures({ + contentRef: outputContentRef, + externalWrapText: wrapText, + onWrapTextChange: setWrapText, + }) - // Training controls state const [isTrainingEnvEnabled, setIsTrainingEnvEnabled] = useState(false) const showTrainingControls = useGeneralStore((state) => state.showTrainingControls) const { isTraining, toggleModal: toggleTrainingModal, stopTraining } = useCopilotTrainingStore() - // Playground state const [isPlaygroundEnabled, setIsPlaygroundEnabled] = useState(false) - // Terminal resize hooks const { handleMouseDown } = useTerminalResize() const { handleMouseDown: handleOutputPanelResizeMouseDown } = useOutputPanelResize() - // Terminal filters hook const { filters, sortConfig, @@ -370,12 +378,10 @@ export function Terminal() { hasActiveFilters, } = useTerminalFilters() - // Context menu state const [hasSelection, setHasSelection] = useState(false) const [contextMenuEntry, setContextMenuEntry] = useState(null) const [storedSelectionText, setStoredSelectionText] = useState('') - // Context menu hooks const { isOpen: isLogRowMenuOpen, position: logRowMenuPosition, @@ -577,44 +583,6 @@ export function Terminal() { } }, [activeWorkflowId, clearWorkflowConsole]) - const activateOutputSearch = useCallback(() => { - setIsOutputSearchActive(true) - setTimeout(() => { - outputSearchInputRef.current?.focus() - }, 0) - }, []) - - const closeOutputSearch = useCallback(() => { - setIsOutputSearchActive(false) - setOutputSearchQuery('') - setMatchCount(0) - setCurrentMatchIndex(0) - }, []) - - /** - * Navigates to the next match in the search results. - */ - const goToNextMatch = useCallback(() => { - if (matchCount === 0) return - setCurrentMatchIndex((prev) => (prev + 1) % matchCount) - }, [matchCount]) - - /** - * Navigates to the previous match in the search results. - */ - const goToPreviousMatch = useCallback(() => { - if (matchCount === 0) return - setCurrentMatchIndex((prev) => (prev - 1 + matchCount) % matchCount) - }, [matchCount]) - - /** - * Handles match count change from Code.Viewer. - */ - const handleMatchCountChange = useCallback((count: number) => { - setMatchCount(count) - setCurrentMatchIndex(0) - }, []) - const handleClearConsole = useCallback( (e: React.MouseEvent) => { e.stopPropagation() @@ -683,6 +651,14 @@ export function Terminal() { [toggleRunId, closeLogRowMenu] ) + const handleCopyRunId = useCallback( + (runId: string) => { + navigator.clipboard.writeText(runId) + closeLogRowMenu() + }, + [closeLogRowMenu] + ) + const handleClearConsoleFromMenu = useCallback(() => { clearCurrentWorkflowConsole() }, [clearCurrentWorkflowConsole]) @@ -885,66 +861,20 @@ export function Terminal() { }, [expandToLastHeight, selectedEntry, showInput, hasInputData, isExpanded]) /** - * Handle Escape to close search or unselect entry - */ - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - // First close search if active - if (isOutputSearchActive) { - closeOutputSearch() - return - } - // Then unselect entry - if (selectedEntry) { - setSelectedEntry(null) - setAutoSelectEnabled(true) - } - } - } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedEntry, isOutputSearchActive, closeOutputSearch]) - - /** - * Handle Enter/Shift+Enter for search navigation when search input is focused + * Handle Escape to unselect entry (search close is handled by useCodeViewerFeatures) */ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (!isOutputSearchActive) return - - const isSearchInputFocused = document.activeElement === outputSearchInputRef.current - - if (e.key === 'Enter' && isSearchInputFocused && matchCount > 0) { + if (e.key === 'Escape' && !isOutputSearchActive && selectedEntry) { e.preventDefault() - if (e.shiftKey) { - goToPreviousMatch() - } else { - goToNextMatch() - } + setSelectedEntry(null) + setAutoSelectEnabled(true) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isOutputSearchActive, matchCount, goToNextMatch, goToPreviousMatch]) - - /** - * Scroll to current match when it changes - */ - useEffect(() => { - if (!isOutputSearchActive || matchCount === 0 || !outputContentRef.current) return - - // Find all match elements and scroll to the current one - const matchElements = outputContentRef.current.querySelectorAll('[data-search-match]') - const currentElement = matchElements[currentMatchIndex] - - if (currentElement) { - currentElement.scrollIntoView({ block: 'center' }) - } - }, [currentMatchIndex, isOutputSearchActive, matchCount]) + }, [selectedEntry, isOutputSearchActive]) /** * Adjust output panel width when sidebar or panel width changes. @@ -1414,25 +1344,16 @@ export function Terminal() {
    {/* Run ID */} - - - - {formatRunId(entry.executionId)} - - - {entry.executionId && ( - - {entry.executionId} - + + style={{ color: runIdColor?.text || '#D2D2D2' }} + > + {formatRunId(entry.executionId)} + {/* Duration */} { clearFilters() closeLogRowMenu() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx index 2db914d741..0f559f46a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx @@ -1,16 +1,19 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' -import { ChevronDown as ChevronDownIcon, X } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ArrowDown, ArrowUp, ChevronDown as ChevronDownIcon, X } from 'lucide-react' import { ReactFlowProvider } from 'reactflow' -import { Badge, Button, ChevronDown, Code } from '@/components/emcn' +import { Badge, Button, ChevronDown, Code, Input } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { extractReferencePrefixes } from '@/lib/workflows/sanitization/references' +import { SnapshotContextMenu } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/components' import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components' +import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { getBlock } from '@/blocks' import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types' import { normalizeName } from '@/executor/constants' import { navigatePath } from '@/executor/variables/resolvers/reference' +import { useCodeViewerFeatures } from '@/hooks/use-code-viewer' import type { BlockState } from '@/stores/workflows/workflow/types' /** @@ -87,16 +90,37 @@ interface ResolvedConnection { fields: Array<{ path: string; value: string; tag: string }> } +interface ExtractedReferences { + blockRefs: string[] + workflowVars: string[] + envVars: string[] +} + /** * Extract all variable references from nested subblock values */ -function extractAllReferencesFromSubBlocks(subBlockValues: Record): string[] { - const refs = new Set() +function extractAllReferencesFromSubBlocks( + subBlockValues: Record +): ExtractedReferences { + const blockRefs = new Set() + const workflowVars = new Set() + const envVars = new Set() const processValue = (value: unknown) => { if (typeof value === 'string') { const extracted = extractReferencePrefixes(value) - extracted.forEach((ref) => refs.add(ref.raw)) + for (const ref of extracted) { + if (ref.prefix === 'variable') { + workflowVars.add(ref.raw) + } else { + blockRefs.add(ref.raw) + } + } + + const envMatches = value.match(/\{\{([^}]+)\}\}/g) + if (envMatches) { + envMatches.forEach((match) => envVars.add(match)) + } } else if (Array.isArray(value)) { value.forEach(processValue) } else if (value && typeof value === 'object') { @@ -109,7 +133,11 @@ function extractAllReferencesFromSubBlocks(subBlockValues: Record void + contentRef?: React.RefObject + onContextMenu?: (e: React.MouseEvent) => void } /** * Collapsible section for execution data (input/output) * Uses Code.Viewer for proper syntax highlighting matching the logs UI */ -function ExecutionDataSection({ title, data, isError = false }: ExecutionDataSectionProps) { +function ExecutionDataSection({ + title, + data, + isError = false, + wrapText = true, + searchQuery, + currentMatchIndex = 0, + onMatchCountChange, + contentRef, + onContextMenu, +}: ExecutionDataSectionProps) { const [isExpanded, setIsExpanded] = useState(false) const jsonString = useMemo(() => { @@ -192,12 +236,17 @@ function ExecutionDataSection({ title, data, isError = false }: ExecutionDataSec No data
    ) : ( - +
    + +
    )} )} @@ -205,18 +254,40 @@ function ExecutionDataSection({ title, data, isError = false }: ExecutionDataSec ) } +interface ResolvedVariable { + ref: string + name: string + value: string +} + +interface ConnectionsSectionProps { + connections: ResolvedConnection[] + workflowVars: ResolvedVariable[] + envVars: ResolvedVariable[] + onContextMenu?: (e: React.MouseEvent, value: string) => void +} + /** * Section showing resolved variable references - styled like the connections section in editor */ -function ResolvedConnectionsSection({ connections }: { connections: ResolvedConnection[] }) { +function ConnectionsSection({ + connections, + workflowVars, + envVars, + onContextMenu, +}: ConnectionsSectionProps) { const [isCollapsed, setIsCollapsed] = useState(false) const [expandedBlocks, setExpandedBlocks] = useState>(new Set()) + const [expandedVariables, setExpandedVariables] = useState(true) + const [expandedEnvVars, setExpandedEnvVars] = useState(true) useEffect(() => { setExpandedBlocks(new Set(connections.map((c) => c.blockId))) }, [connections]) - if (connections.length === 0) return null + const hasContent = connections.length > 0 || workflowVars.length > 0 || envVars.length > 0 + + if (!hasContent) return null const toggleBlock = (blockId: string) => { setExpandedBlocks((prev) => { @@ -230,8 +301,14 @@ function ResolvedConnectionsSection({ connections }: { connections: ResolvedConn }) } + const handleValueContextMenu = (e: React.MouseEvent, value: string) => { + if (value && value !== '—' && value !== '[REDACTED]' && onContextMenu) { + onContextMenu(e, value) + } + } + return ( -
    +
    {/* Header with Chevron */}
    (
    handleValueContextMenu(e, field.value)} > {field.path} - + {field.value}
    @@ -332,6 +410,101 @@ function ResolvedConnectionsSection({ connections }: { connections: ResolvedConn
    ) })} + + {/* Workflow Variables */} + {workflowVars.length > 0 && ( +
    +
    setExpandedVariables(!expandedVariables)} + > +
    + V +
    + + Variables + + +
    + {expandedVariables && ( +
    +
    + {workflowVars.map((v) => ( +
    handleValueContextMenu(e, v.value)} + > + + {v.name} + + + {v.value} + +
    + ))} +
    + )} +
    + )} + + {/* Environment Variables */} + {envVars.length > 0 && ( +
    +
    setExpandedEnvVars(!expandedEnvVars)} + > +
    + E +
    + + Environment Variables + + +
    + {expandedEnvVars && ( +
    +
    + {envVars.map((v) => ( +
    + + {v.name} + + + {v.value} + +
    + ))} +
    + )} +
    + )}
    )}
    @@ -359,6 +532,13 @@ interface ExecutionData { durationMs?: number } +interface WorkflowVariable { + id: string + name: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' + value: unknown +} + interface BlockDetailsSidebarProps { block: BlockState executionData?: ExecutionData @@ -366,6 +546,8 @@ interface BlockDetailsSidebarProps { allBlockExecutions?: Record /** All workflow blocks for mapping block names to IDs */ workflowBlocks?: Record + /** Workflow variables for resolving variable references */ + workflowVariables?: Record /** When true, shows "Not Executed" badge if no executionData is provided */ isExecutionMode?: boolean /** Optional close handler - if not provided, no close button is shown */ @@ -388,12 +570,95 @@ function BlockDetailsSidebarContent({ executionData, allBlockExecutions, workflowBlocks, + workflowVariables, isExecutionMode = false, onClose, }: BlockDetailsSidebarProps) { + // Convert Record to Array for iteration + const normalizedWorkflowVariables = useMemo(() => { + if (!workflowVariables) return [] + return Object.values(workflowVariables) + }, [workflowVariables]) + const blockConfig = getBlock(block.type) as BlockConfig | undefined const subBlockValues = block.subBlocks || {} + const contentRef = useRef(null) + + const { + wrapText, + toggleWrapText, + isSearchActive, + searchQuery, + setSearchQuery, + matchCount, + currentMatchIndex, + activateSearch, + closeSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef, + } = useCodeViewerFeatures({ contentRef }) + + const { + isOpen: isContextMenuOpen, + position: contextMenuPosition, + menuRef: contextMenuRef, + handleContextMenu, + closeMenu: closeContextMenu, + } = useContextMenu() + + const [contextMenuData, setContextMenuData] = useState({ content: '', copyOnly: false }) + + const openContextMenu = useCallback( + (e: React.MouseEvent, content: string, copyOnly: boolean) => { + setContextMenuData({ content, copyOnly }) + handleContextMenu(e) + }, + [handleContextMenu] + ) + + const handleExecutionContextMenu = useCallback( + (e: React.MouseEvent) => { + const parts: string[] = [] + if (executionData?.input) { + parts.push(`// Input\n${formatValueAsJson(executionData.input)}`) + } + if (executionData?.output) { + parts.push(`// Output\n${formatValueAsJson(executionData.output)}`) + } + if (parts.length > 0) { + openContextMenu(e, parts.join('\n\n'), false) + } + }, + [executionData, openContextMenu] + ) + + const handleSubblockContextMenu = useCallback( + (e: React.MouseEvent, config: SubBlockConfig) => { + if (config.password || config.type === 'oauth-input') return + + const valueObj = subBlockValues[config.id] + const value = + valueObj && typeof valueObj === 'object' && 'value' in valueObj + ? (valueObj as { value: unknown }).value + : valueObj + + if (value !== undefined && value !== null && value !== '') { + const content = typeof value === 'string' ? value : JSON.stringify(value, null, 2) + openContextMenu(e, content, true) + } + }, + [subBlockValues, openContextMenu] + ) + + const handleCopy = useCallback(() => { + if (contextMenuData.content) { + navigator.clipboard.writeText(contextMenuData.content) + } + }, [contextMenuData.content]) + const blockNameToId = useMemo(() => { const map = new Map() if (workflowBlocks) { @@ -432,18 +697,20 @@ function BlockDetailsSidebarContent({ } }, [allBlockExecutions, workflowBlocks, blockNameToId]) - // Group resolved variables by source block for display + const extractedRefs = useMemo( + () => extractAllReferencesFromSubBlocks(subBlockValues), + [subBlockValues] + ) + const resolvedConnections = useMemo((): ResolvedConnection[] => { if (!allBlockExecutions || !workflowBlocks) return [] - const allRefs = extractAllReferencesFromSubBlocks(subBlockValues) const seen = new Set() const blockMap = new Map() - for (const ref of allRefs) { + for (const ref of extractedRefs.blockRefs) { if (seen.has(ref)) continue - // Parse reference: const inner = ref.slice(1, -1) const parts = inner.split('.') if (parts.length < 1) continue @@ -461,7 +728,6 @@ function BlockDetailsSidebarContent({ seen.add(ref) - // Get or create block entry if (!blockMap.has(blockId)) { blockMap.set(blockId, { blockId, @@ -480,7 +746,35 @@ function BlockDetailsSidebarContent({ } return Array.from(blockMap.values()) - }, [subBlockValues, allBlockExecutions, workflowBlocks, blockNameToId, resolveReference]) + }, [extractedRefs.blockRefs, allBlockExecutions, workflowBlocks, blockNameToId, resolveReference]) + + const resolvedWorkflowVars = useMemo((): ResolvedVariable[] => { + return extractedRefs.workflowVars.map((ref) => { + const inner = ref.slice(1, -1) + const parts = inner.split('.') + const varName = parts.slice(1).join('.') + + let value = '—' + if (normalizedWorkflowVariables.length > 0) { + const normalizedVarName = normalizeName(varName) + const matchedVar = normalizedWorkflowVariables.find( + (v) => normalizeName(v.name) === normalizedVarName + ) + if (matchedVar !== undefined) { + value = formatInlineValue(matchedVar.value) + } + } + + return { ref, name: varName, value } + }) + }, [extractedRefs.workflowVars, normalizedWorkflowVariables]) + + const resolvedEnvVars = useMemo((): ResolvedVariable[] => { + return extractedRefs.envVars.map((ref) => { + const varName = ref.slice(2, -2) + return { ref, name: varName, value: '[REDACTED]' } + }) + }, [extractedRefs.envVars]) if (!blockConfig) { return ( @@ -515,7 +809,7 @@ function BlockDetailsSidebarContent({ : 'gray' return ( -
    +
    {/* Header - styled like editor */}
    + )} {/* Divider between Input and Output */} @@ -597,6 +900,12 @@ function BlockDetailsSidebarContent({ title={executionData.status === 'error' ? 'Error' : 'Output'} data={executionData.output} isError={executionData.status === 'error'} + wrapText={wrapText} + searchQuery={isSearchActive ? searchQuery : undefined} + currentMatchIndex={currentMatchIndex} + onMatchCountChange={handleMatchCountChange} + contentRef={contentRef} + onContextMenu={handleExecutionContextMenu} /> )}
    @@ -628,7 +937,11 @@ function BlockDetailsSidebarContent({ {visibleSubBlocks.length > 0 ? (
    {visibleSubBlocks.map((subBlockConfig, index) => ( -
    +
    handleSubblockContextMenu(e, subBlockConfig)} + > )}
    + + {/* Connections Section */} + openContextMenu(e, value, true)} + />
    - {/* Resolved Variables Section - Pinned at bottom, outside scrollable area */} - {resolvedConnections.length > 0 && ( - + {/* Search Overlay */} + {isSearchActive && ( +
    e.stopPropagation()} + > + setSearchQuery(e.target.value)} + placeholder='Search...' + className='mr-[2px] h-[23px] w-[94px] text-[12px]' + /> + 0 ? 'text-[var(--text-secondary)]' : 'text-[var(--text-tertiary)]' + )} + > + {matchCount > 0 ? `${currentMatchIndex + 1}/${matchCount}` : '0/0'} + + + + +
    )} + + {/* Context Menu */} +
    ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx index b617d3da59..a5a9368854 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx @@ -39,6 +39,8 @@ interface WorkflowPreviewProps { defaultZoom?: number fitPadding?: number onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void + /** Callback when a node is right-clicked */ + onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void /** Use lightweight blocks for better performance in template cards */ lightweight?: boolean /** Cursor style to show when hovering the canvas */ @@ -108,6 +110,7 @@ export function WorkflowPreview({ defaultZoom = 0.8, fitPadding = 0.25, onNodeClick, + onNodeContextMenu, lightweight = false, cursorStyle = 'grab', executedBlocks, @@ -414,6 +417,15 @@ export function WorkflowPreview({ } : undefined } + onNodeContextMenu={ + onNodeContextMenu + ? (event, node) => { + event.preventDefault() + event.stopPropagation() + onNodeContextMenu(node.id, { x: event.clientX, y: event.clientY }) + } + : undefined + } />
    diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx index 63c6748519..050f2757cc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx @@ -165,7 +165,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { const { data: session } = useSession() const queryClient = useQueryClient() const { data: organizationsData } = useOrganizations() - const { data: subscriptionData } = useSubscriptionData() + const { data: subscriptionData } = useSubscriptionData({ enabled: isBillingEnabled }) const { data: ssoProvidersData, isLoading: isLoadingSSO } = useSSOProviders() const activeOrganization = organizationsData?.activeOrganization diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts index 77b7637bbd..f2a0d13f12 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workflow.ts @@ -4,6 +4,7 @@ import JSZip from 'jszip' import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import type { Variable } from '@/stores/workflows/workflow/types' const logger = createLogger('useExportWorkflow') @@ -122,17 +123,12 @@ export function useExportWorkflow({ continue } - // Fetch workflow variables + // Fetch workflow variables (API returns Record format directly) const variablesResponse = await fetch(`/api/workflows/${workflowId}/variables`) - let workflowVariables: any[] = [] + let workflowVariables: Record | undefined if (variablesResponse.ok) { const variablesData = await variablesResponse.json() - workflowVariables = Object.values(variablesData?.data || {}).map((v: any) => ({ - id: v.id, - name: v.name, - type: v.type, - value: v.value, - })) + workflowVariables = variablesData?.data } // Prepare export state diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts index 6856cc099b..1d25315a3e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-export-workspace.ts @@ -2,8 +2,10 @@ import { useCallback, useState } from 'react' import { createLogger } from '@sim/logger' import { exportWorkspaceToZip, + type FolderExportData, type WorkflowExportData, } from '@/lib/workflows/operations/import-export' +import type { Variable } from '@/stores/workflows/workflow/types' const logger = createLogger('useExportWorkspace') @@ -74,15 +76,10 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {}) } const variablesResponse = await fetch(`/api/workflows/${workflow.id}/variables`) - let workflowVariables: any[] = [] + let workflowVariables: Record | undefined if (variablesResponse.ok) { const variablesData = await variablesResponse.json() - workflowVariables = Object.values(variablesData?.data || {}).map((v: any) => ({ - id: v.id, - name: v.name, - type: v.type, - value: v.value, - })) + workflowVariables = variablesData?.data } workflowsToExport.push({ @@ -101,15 +98,13 @@ export function useExportWorkspace({ onSuccess }: UseExportWorkspaceProps = {}) } } - const foldersToExport: Array<{ - id: string - name: string - parentId: string | null - }> = (foldersData.folders || []).map((folder: any) => ({ - id: folder.id, - name: folder.name, - parentId: folder.parentId, - })) + const foldersToExport: FolderExportData[] = (foldersData.folders || []).map( + (folder: FolderExportData) => ({ + id: folder.id, + name: folder.name, + parentId: folder.parentId, + }) + ) const zipBlob = await exportWorkspaceToZip( workspaceName, diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts index 00c46a00a3..d4f294e7f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workflow.ts @@ -79,21 +79,36 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) { body: JSON.stringify(workflowData), }) - // Save variables if any - if (workflowData.variables && workflowData.variables.length > 0) { - const variablesPayload = workflowData.variables.map((v: any) => ({ - id: typeof v.id === 'string' && v.id.trim() ? v.id : crypto.randomUUID(), - workflowId: newWorkflowId, - name: v.name, - type: v.type, - value: v.value, - })) - - await fetch(`/api/workflows/${newWorkflowId}/variables`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ variables: variablesPayload }), - }) + // Save variables if any (handle both legacy Array and current Record formats) + if (workflowData.variables) { + // Convert to Record format for API (handles backwards compatibility with old Array exports) + const variablesArray = Array.isArray(workflowData.variables) + ? workflowData.variables + : Object.values(workflowData.variables) + + if (variablesArray.length > 0) { + const variablesRecord: Record< + string, + { id: string; workflowId: string; name: string; type: string; value: unknown } + > = {} + + for (const v of variablesArray) { + const id = typeof v.id === 'string' && v.id.trim() ? v.id : crypto.randomUUID() + variablesRecord[id] = { + id, + workflowId: newWorkflowId, + name: v.name, + type: v.type, + value: v.value, + } + } + + await fetch(`/api/workflows/${newWorkflowId}/variables`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ variables: variablesRecord }), + }) + } } logger.info(`Imported workflow: ${workflowName}`) diff --git a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts index b71487734b..1ad051307b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/hooks/use-import-workspace.ts @@ -159,21 +159,36 @@ export function useImportWorkspace({ onSuccess }: UseImportWorkspaceProps = {}) continue } - // Save variables if any - if (workflowData.variables && workflowData.variables.length > 0) { - const variablesPayload = workflowData.variables.map((v: any) => ({ - id: typeof v.id === 'string' && v.id.trim() ? v.id : crypto.randomUUID(), - workflowId: newWorkflow.id, - name: v.name, - type: v.type, - value: v.value, - })) - - await fetch(`/api/workflows/${newWorkflow.id}/variables`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ variables: variablesPayload }), - }) + // Save variables if any (handle both legacy Array and current Record formats) + if (workflowData.variables) { + // Convert to Record format for API (handles backwards compatibility with old Array exports) + const variablesArray = Array.isArray(workflowData.variables) + ? workflowData.variables + : Object.values(workflowData.variables) + + if (variablesArray.length > 0) { + const variablesRecord: Record< + string, + { id: string; workflowId: string; name: string; type: string; value: unknown } + > = {} + + for (const v of variablesArray) { + const id = typeof v.id === 'string' && v.id.trim() ? v.id : crypto.randomUUID() + variablesRecord[id] = { + id, + workflowId: newWorkflow.id, + name: v.name, + type: v.type, + value: v.value, + } + } + + await fetch(`/api/workflows/${newWorkflow.id}/variables`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ variables: variablesRecord }), + }) + } } logger.info(`Imported workflow: ${workflowName}`) diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index f483bbfc78..7a5d06f405 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -1,3 +1,5 @@ +import type { LoopType, ParallelType } from '@/lib/workflows/types' + export enum BlockType { PARALLEL = 'parallel', LOOP = 'loop', @@ -40,12 +42,8 @@ export const METADATA_ONLY_BLOCK_TYPES = [ BlockType.NOTE, ] as const -export type LoopType = 'for' | 'forEach' | 'while' | 'doWhile' - export type SentinelType = 'start' | 'end' -export type ParallelType = 'collection' | 'count' - export const EDGE = { CONDITION_PREFIX: 'condition-', CONDITION_TRUE: 'condition-true', diff --git a/apps/sim/hooks/queries/subscription.ts b/apps/sim/hooks/queries/subscription.ts index 89ded91231..b0e40ef6c9 100644 --- a/apps/sim/hooks/queries/subscription.ts +++ b/apps/sim/hooks/queries/subscription.ts @@ -28,6 +28,8 @@ async function fetchSubscriptionData(includeOrg = false) { interface UseSubscriptionDataOptions { /** Include organization membership and role data */ includeOrg?: boolean + /** Whether to enable the query (defaults to true) */ + enabled?: boolean } /** @@ -35,13 +37,14 @@ interface UseSubscriptionDataOptions { * @param options - Optional configuration */ export function useSubscriptionData(options: UseSubscriptionDataOptions = {}) { - const { includeOrg = false } = options + const { includeOrg = false, enabled = true } = options return useQuery({ queryKey: subscriptionKeys.user(includeOrg), queryFn: () => fetchSubscriptionData(includeOrg), staleTime: 30 * 1000, placeholderData: keepPreviousData, + enabled, }) } @@ -58,17 +61,25 @@ async function fetchUsageLimitData() { return response.json() } +interface UseUsageLimitDataOptions { + /** Whether to enable the query (defaults to true) */ + enabled?: boolean +} + /** * Hook to fetch usage limit metadata * Returns: currentLimit, minimumLimit, canEdit, plan, updatedAt * Use this for editing usage limits, not for displaying current usage */ -export function useUsageLimitData() { +export function useUsageLimitData(options: UseUsageLimitDataOptions = {}) { + const { enabled = true } = options + return useQuery({ queryKey: subscriptionKeys.usage(), queryFn: fetchUsageLimitData, staleTime: 30 * 1000, placeholderData: keepPreviousData, + enabled, }) } diff --git a/apps/sim/hooks/use-code-viewer.ts b/apps/sim/hooks/use-code-viewer.ts new file mode 100644 index 0000000000..52d0300970 --- /dev/null +++ b/apps/sim/hooks/use-code-viewer.ts @@ -0,0 +1,155 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +interface UseCodeViewerFeaturesOptions { + /** Reference to the content container for scroll-to-match functionality */ + contentRef?: React.RefObject + /** Initial wrap text state (ignored if externalWrapText is provided) */ + initialWrapText?: boolean + /** External wrap text state (e.g., from Zustand store) */ + externalWrapText?: boolean + /** External setter for wrap text (required if externalWrapText is provided) */ + onWrapTextChange?: (wrap: boolean) => void + /** Callback when escape is pressed (optional, for custom handling) */ + onEscape?: () => void +} + +interface UseCodeViewerFeaturesReturn { + wrapText: boolean + setWrapText: (wrap: boolean) => void + toggleWrapText: () => void + + isSearchActive: boolean + searchQuery: string + setSearchQuery: (query: string) => void + matchCount: number + currentMatchIndex: number + activateSearch: () => void + closeSearch: () => void + goToNextMatch: () => void + goToPreviousMatch: () => void + handleMatchCountChange: (count: number) => void + searchInputRef: React.RefObject +} + +/** + * Reusable hook for Code.Viewer features: search and wrap text functionality. + * Supports both internal state and external state (e.g., from Zustand) for wrapText. + */ +export function useCodeViewerFeatures( + options: UseCodeViewerFeaturesOptions = {} +): UseCodeViewerFeaturesReturn { + const { + contentRef, + initialWrapText = true, + externalWrapText, + onWrapTextChange, + onEscape, + } = options + + // Use external state if provided, otherwise use internal state + const [internalWrapText, setInternalWrapText] = useState(initialWrapText) + const wrapText = externalWrapText !== undefined ? externalWrapText : internalWrapText + const setWrapText = onWrapTextChange ?? setInternalWrapText + + const [isSearchActive, setIsSearchActive] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [matchCount, setMatchCount] = useState(0) + const [currentMatchIndex, setCurrentMatchIndex] = useState(0) + const searchInputRef = useRef(null) + + const toggleWrapText = useCallback(() => { + setWrapText(!wrapText) + }, [wrapText, setWrapText]) + + const activateSearch = useCallback(() => { + setIsSearchActive(true) + setTimeout(() => { + searchInputRef.current?.focus() + }, 0) + }, []) + + const closeSearch = useCallback(() => { + setIsSearchActive(false) + setSearchQuery('') + setMatchCount(0) + setCurrentMatchIndex(0) + }, []) + + const goToNextMatch = useCallback(() => { + if (matchCount === 0) return + setCurrentMatchIndex((prev) => (prev + 1) % matchCount) + }, [matchCount]) + + const goToPreviousMatch = useCallback(() => { + if (matchCount === 0) return + setCurrentMatchIndex((prev) => (prev - 1 + matchCount) % matchCount) + }, [matchCount]) + + const handleMatchCountChange = useCallback((count: number) => { + setMatchCount(count) + setCurrentMatchIndex(0) + }, []) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isSearchActive) { + e.preventDefault() + closeSearch() + onEscape?.() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isSearchActive, closeSearch, onEscape]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!isSearchActive) return + + const isSearchInputFocused = document.activeElement === searchInputRef.current + + if (e.key === 'Enter' && isSearchInputFocused && matchCount > 0) { + e.preventDefault() + if (e.shiftKey) { + goToPreviousMatch() + } else { + goToNextMatch() + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isSearchActive, matchCount, goToNextMatch, goToPreviousMatch]) + + useEffect(() => { + if (!isSearchActive || matchCount === 0 || !contentRef?.current) return + + const matchElements = contentRef.current.querySelectorAll('[data-search-match]') + const currentElement = matchElements[currentMatchIndex] + + if (currentElement) { + currentElement.scrollIntoView({ block: 'center' }) + } + }, [currentMatchIndex, isSearchActive, matchCount, contentRef]) + + return { + wrapText, + setWrapText, + toggleWrapText, + isSearchActive, + searchQuery, + setSearchQuery, + matchCount, + currentMatchIndex, + activateSearch, + closeSearch, + goToNextMatch, + goToPreviousMatch, + handleMatchCountChange, + searchInputRef, + } +} diff --git a/apps/sim/hooks/use-forwarded-ref.ts b/apps/sim/hooks/use-forwarded-ref.ts deleted file mode 100644 index 70bbc4ad37..0000000000 --- a/apps/sim/hooks/use-forwarded-ref.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { type MutableRefObject, useEffect, useRef } from 'react' - -/** - * A hook that handles forwarded refs and returns a mutable ref object - * Useful for components that need both a forwarded ref and a local ref - * @param forwardedRef The forwarded ref from React.forwardRef - * @returns A mutable ref object that can be used locally - */ -export function useForwardedRef( - forwardedRef: React.ForwardedRef -): MutableRefObject { - const innerRef = useRef(null) - - useEffect(() => { - if (!forwardedRef) return - - if (typeof forwardedRef === 'function') { - forwardedRef(innerRef.current) - } else { - forwardedRef.current = innerRef.current - } - }, [forwardedRef]) - - return innerRef -} diff --git a/apps/sim/hooks/use-subscription-state.ts b/apps/sim/hooks/use-subscription-state.ts deleted file mode 100644 index 5bb52ad135..0000000000 --- a/apps/sim/hooks/use-subscription-state.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { createLogger } from '@sim/logger' -import { DEFAULT_FREE_CREDITS } from '@/lib/billing/constants' - -const logger = createLogger('useSubscriptionState') - -interface UsageData { - current: number - limit: number - percentUsed: number - isWarning: boolean - isExceeded: boolean - billingPeriodStart: Date | null - billingPeriodEnd: Date | null - lastPeriodCost: number -} - -interface SubscriptionState { - isPaid: boolean - isPro: boolean - isTeam: boolean - isEnterprise: boolean - plan: string - status: string | null - seats: number | null - metadata: any | null - usage: UsageData -} - -/** - * Consolidated hook for subscription state management - * Combines subscription status, features, and usage data - */ -export function useSubscriptionState() { - const [data, setData] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchSubscriptionState = useCallback(async () => { - try { - setIsLoading(true) - setError(null) - - const response = await fetch('/api/billing?context=user') - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const result = await response.json() - const subscriptionData = result.data - setData(subscriptionData) - } catch (error) { - const err = error instanceof Error ? error : new Error('Failed to fetch subscription state') - logger.error('Failed to fetch subscription state', { error }) - setError(err) - } finally { - setIsLoading(false) - } - }, []) - - useEffect(() => { - fetchSubscriptionState() - }, [fetchSubscriptionState]) - - const refetch = useCallback(() => { - return fetchSubscriptionState() - }, [fetchSubscriptionState]) - - return { - subscription: { - isPaid: data?.isPaid ?? false, - isPro: data?.isPro ?? false, - isTeam: data?.isTeam ?? false, - isEnterprise: data?.isEnterprise ?? false, - isFree: !(data?.isPaid ?? false), - plan: data?.plan ?? 'free', - status: data?.status, - seats: data?.seats, - metadata: data?.metadata, - }, - - usage: { - current: data?.usage?.current ?? 0, - limit: data?.usage?.limit ?? DEFAULT_FREE_CREDITS, - percentUsed: data?.usage?.percentUsed ?? 0, - isWarning: data?.usage?.isWarning ?? false, - isExceeded: data?.usage?.isExceeded ?? false, - billingPeriodStart: data?.usage?.billingPeriodStart - ? new Date(data.usage.billingPeriodStart) - : null, - billingPeriodEnd: data?.usage?.billingPeriodEnd - ? new Date(data.usage.billingPeriodEnd) - : null, - lastPeriodCost: data?.usage?.lastPeriodCost ?? 0, - }, - - isLoading, - error, - refetch, - - isAtLeastPro: () => { - return data?.isPro || data?.isTeam || data?.isEnterprise || false - }, - - isAtLeastTeam: () => { - return data?.isTeam || data?.isEnterprise || false - }, - - canUpgrade: () => { - return data?.plan === 'free' || data?.plan === 'pro' - }, - - getBillingStatus: () => { - const usage = data?.usage - if (!usage) return 'unknown' - - if (usage.isExceeded) return 'exceeded' - if (usage.isWarning) return 'warning' - return 'ok' - }, - - getRemainingBudget: () => { - const usage = data?.usage - if (!usage) return 0 - return Math.max(0, usage.limit - usage.current) - }, - - getDaysRemainingInPeriod: () => { - const usage = data?.usage - if (!usage?.billingPeriodEnd) return null - - const now = new Date() - const endDate = new Date(usage.billingPeriodEnd) - const diffTime = endDate.getTime() - now.getTime() - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) - - return Math.max(0, diffDays) - }, - } -} - -/** - * Hook for usage limit information with editing capabilities - */ -export function useUsageLimit() { - const [data, setData] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - - const fetchUsageLimit = useCallback(async () => { - try { - setIsLoading(true) - setError(null) - - const response = await fetch('/api/usage?context=user') - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const limitData = await response.json() - setData(limitData) - } catch (error) { - const err = error instanceof Error ? error : new Error('Failed to fetch usage limit') - logger.error('Failed to fetch usage limit', { error }) - setError(err) - } finally { - setIsLoading(false) - } - }, []) - - useEffect(() => { - fetchUsageLimit() - }, [fetchUsageLimit]) - - const refetch = useCallback(() => { - return fetchUsageLimit() - }, [fetchUsageLimit]) - - const updateLimit = async (newLimit: number) => { - try { - const response = await fetch('/api/usage?context=user', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ limit: newLimit }), - }) - - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || 'Failed to update usage limit') - } - - await refetch() - - return { success: true } - } catch (error) { - logger.error('Failed to update usage limit', { error, newLimit }) - throw error - } - } - - return { - currentLimit: data?.currentLimit ?? DEFAULT_FREE_CREDITS, - canEdit: data?.canEdit ?? false, - minimumLimit: data?.minimumLimit ?? DEFAULT_FREE_CREDITS, - plan: data?.plan ?? 'free', - setBy: data?.setBy, - updatedAt: data?.updatedAt ? new Date(data.updatedAt) : null, - updateLimit, - isLoading, - error, - refetch, - } -} diff --git a/apps/sim/lib/logs/execution/logging-factory.ts b/apps/sim/lib/logs/execution/logging-factory.ts index 5d5e5f8eb3..3e7cad61f5 100644 --- a/apps/sim/lib/logs/execution/logging-factory.ts +++ b/apps/sim/lib/logs/execution/logging-factory.ts @@ -1,3 +1,5 @@ +import { db, workflow } from '@sim/db' +import { eq } from 'drizzle-orm' import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants' import type { ExecutionEnvironment, ExecutionTrigger, WorkflowState } from '@/lib/logs/types' import { @@ -34,7 +36,15 @@ export function createEnvironmentObject( } export async function loadWorkflowStateForExecution(workflowId: string): Promise { - const normalizedData = await loadWorkflowFromNormalizedTables(workflowId) + const [normalizedData, workflowRecord] = await Promise.all([ + loadWorkflowFromNormalizedTables(workflowId), + db + .select({ variables: workflow.variables }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + .then((rows) => rows[0]), + ]) if (!normalizedData) { throw new Error( @@ -47,6 +57,7 @@ export async function loadWorkflowStateForExecution(workflowId: string): Promise edges: normalizedData.edges || [], loops: normalizedData.loops || {}, parallels: normalizedData.parallels || {}, + variables: (workflowRecord?.variables as WorkflowState['variables']) || undefined, } } @@ -65,6 +76,7 @@ export async function loadDeployedWorkflowStateForLogging( edges: deployedData.edges || [], loops: deployedData.loops || {}, parallels: deployedData.parallels || {}, + variables: deployedData.variables, } } diff --git a/apps/sim/lib/logs/execution/snapshot/service.test.ts b/apps/sim/lib/logs/execution/snapshot/service.test.ts index 091bdb4a1b..b8568bfaf5 100644 --- a/apps/sim/lib/logs/execution/snapshot/service.test.ts +++ b/apps/sim/lib/logs/execution/snapshot/service.test.ts @@ -211,5 +211,113 @@ describe('SnapshotService', () => { const hash2 = service.computeStateHash(complexState) expect(hash).toBe(hash2) }) + + test('should include variables in hash computation', () => { + const stateWithVariables: WorkflowState = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: { + 'var-1': { + id: 'var-1', + name: 'apiKey', + type: 'string', + value: 'secret123', + }, + }, + } + + const stateWithoutVariables: WorkflowState = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + } + + const hashWith = service.computeStateHash(stateWithVariables) + const hashWithout = service.computeStateHash(stateWithoutVariables) + + expect(hashWith).not.toBe(hashWithout) + }) + + test('should detect changes in variable values', () => { + const state1: WorkflowState = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: { + 'var-1': { + id: 'var-1', + name: 'myVar', + type: 'string', + value: 'value1', + }, + }, + } + + const state2: WorkflowState = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: { + 'var-1': { + id: 'var-1', + name: 'myVar', + type: 'string', + value: 'value2', // Different value + }, + }, + } + + const hash1 = service.computeStateHash(state1) + const hash2 = service.computeStateHash(state2) + + expect(hash1).not.toBe(hash2) + }) + + test('should generate consistent hashes for states with variables', () => { + const stateWithVariables: WorkflowState = { + blocks: { + block1: { + id: 'block1', + name: 'Test', + type: 'agent', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + horizontalHandles: true, + advancedMode: false, + height: 0, + }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: { + 'var-1': { + id: 'var-1', + name: 'testVar', + type: 'plain', + value: 'testValue', + }, + 'var-2': { + id: 'var-2', + name: 'anotherVar', + type: 'number', + value: 42, + }, + }, + } + + const hash1 = service.computeStateHash(stateWithVariables) + const hash2 = service.computeStateHash(stateWithVariables) + + expect(hash1).toBe(hash2) + expect(hash1).toHaveLength(64) + }) }) }) diff --git a/apps/sim/lib/logs/execution/snapshot/service.ts b/apps/sim/lib/logs/execution/snapshot/service.ts index b28e94e529..d753cbbd87 100644 --- a/apps/sim/lib/logs/execution/snapshot/service.ts +++ b/apps/sim/lib/logs/execution/snapshot/service.ts @@ -182,11 +182,15 @@ export class SnapshotService implements ISnapshotService { normalizedParallels[parallelId] = normalizeValue(parallel) } + // 4. Normalize variables (if present) + const normalizedVariables = state.variables ? normalizeValue(state.variables) : undefined + return { blocks: normalizedBlocks, edges: normalizedEdges, loops: normalizedLoops, parallels: normalizedParallels, + ...(normalizedVariables !== undefined && { variables: normalizedVariables }), } } } diff --git a/apps/sim/lib/workflows/autolayout/types.ts b/apps/sim/lib/workflows/autolayout/types.ts index a20c35715a..7f8cf78190 100644 --- a/apps/sim/lib/workflows/autolayout/types.ts +++ b/apps/sim/lib/workflows/autolayout/types.ts @@ -1,5 +1,8 @@ import type { BlockState, Position } from '@/stores/workflows/workflow/types' +export type { Edge } from 'reactflow' +export type { Loop, Parallel } from '@/stores/workflows/workflow/types' + export interface LayoutOptions { horizontalSpacing?: number verticalSpacing?: number @@ -12,30 +15,6 @@ export interface LayoutResult { error?: string } -export interface Edge { - id: string - source: string - target: string - sourceHandle?: string | null - targetHandle?: string | null -} - -export interface Loop { - id: string - nodes: string[] - iterations: number - loopType: 'for' | 'forEach' | 'while' | 'doWhile' - forEachItems?: any[] | Record | string // Items or expression - whileCondition?: string // JS expression that evaluates to boolean -} - -export interface Parallel { - id: string - nodes: string[] - count?: number - parallelType?: 'count' | 'collection' -} - export interface BlockMetrics { width: number height: number diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index 6ea6eef84f..fcd6b8fb88 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -14,7 +14,20 @@ import { getBlock } from '@/blocks' import type { BlockConfig, OutputCondition } from '@/blocks/types' import { getTrigger, isTriggerValid } from '@/triggers' -type OutputDefinition = Record +type OutputDefinition = Record + +interface SubBlockWithValue { + value?: unknown +} + +type ConditionValue = string | number | boolean + +/** + * Checks if a value is a valid primitive for condition comparison. + */ +function isConditionPrimitive(value: unknown): value is ConditionValue { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' +} /** * Evaluates an output condition against subBlock values. @@ -22,7 +35,7 @@ type OutputDefinition = Record */ function evaluateOutputCondition( condition: OutputCondition, - subBlocks: Record | undefined + subBlocks: Record | undefined ): boolean { if (!subBlocks) return false @@ -30,7 +43,8 @@ function evaluateOutputCondition( let matches: boolean if (Array.isArray(condition.value)) { - matches = condition.value.includes(fieldValue) + // For array conditions, check if fieldValue is a valid primitive and included + matches = isConditionPrimitive(fieldValue) && condition.value.includes(fieldValue) } else { matches = fieldValue === condition.value } @@ -44,7 +58,8 @@ function evaluateOutputCondition( let andMatches: boolean if (Array.isArray(condition.and.value)) { - andMatches = condition.and.value.includes(andFieldValue) + andMatches = + isConditionPrimitive(andFieldValue) && condition.and.value.includes(andFieldValue) } else { andMatches = andFieldValue === condition.and.value } @@ -65,7 +80,7 @@ function evaluateOutputCondition( */ function filterOutputsByCondition( outputs: OutputDefinition, - subBlocks: Record | undefined + subBlocks: Record | undefined ): OutputDefinition { const filtered: OutputDefinition = {} @@ -119,7 +134,7 @@ function hasInputFormat(blockConfig: BlockConfig): boolean { } function getTriggerId( - subBlocks: Record | undefined, + subBlocks: Record | undefined, blockConfig: BlockConfig ): string | undefined { const selectedTriggerIdValue = subBlocks?.selectedTriggerId?.value @@ -136,13 +151,17 @@ function getTriggerId( ) } -function getUnifiedStartOutputs(subBlocks: Record | undefined): OutputDefinition { +function getUnifiedStartOutputs( + subBlocks: Record | undefined +): OutputDefinition { const outputs = { ...UNIFIED_START_OUTPUTS } const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value) return applyInputFormatFields(normalizedInputFormat, outputs) } -function getLegacyStarterOutputs(subBlocks: Record | undefined): OutputDefinition { +function getLegacyStarterOutputs( + subBlocks: Record | undefined +): OutputDefinition { const startWorkflowValue = subBlocks?.startWorkflow?.value if (startWorkflowValue === 'chat') { @@ -179,7 +198,7 @@ function shouldClearBaseOutputs( function applyInputFormatToOutputs( blockType: string, blockConfig: BlockConfig, - subBlocks: Record | undefined, + subBlocks: Record | undefined, baseOutputs: OutputDefinition ): OutputDefinition { if (!hasInputFormat(blockConfig) || !subBlocks?.inputFormat?.value) { @@ -203,7 +222,7 @@ function applyInputFormatToOutputs( export function getBlockOutputs( blockType: string, - subBlocks?: Record, + subBlocks?: Record, triggerMode?: boolean ): OutputDefinition { const blockConfig = getBlock(blockType) @@ -226,7 +245,7 @@ export function getBlockOutputs( } if (blockType === 'human_in_the_loop') { - const hitlOutputs: Record = { + const hitlOutputs: OutputDefinition = { url: { type: 'string', description: 'Resume UI URL' }, resumeEndpoint: { type: 'string', @@ -251,7 +270,7 @@ export function getBlockOutputs( if (blockType === 'approval') { // Start with only url (apiUrl commented out - not accessible as output) - const pauseResumeOutputs: Record = { + const pauseResumeOutputs: OutputDefinition = { url: { type: 'string', description: 'Resume UI URL' }, // apiUrl: { type: 'string', description: 'Resume API URL' }, // Commented out - not accessible as output } @@ -285,7 +304,7 @@ function shouldFilterReservedField( blockType: string, key: string, prefix: string, - subBlocks: Record | undefined + subBlocks: Record | undefined ): boolean { if (blockType !== TRIGGER_TYPES.START || prefix) { return false @@ -308,7 +327,7 @@ function expandFileTypeProperties(path: string): string[] { function collectOutputPaths( obj: OutputDefinition, blockType: string, - subBlocks: Record | undefined, + subBlocks: Record | undefined, prefix = '' ): string[] { const paths: string[] = [] @@ -321,13 +340,14 @@ function collectOutputPaths( } if (value && typeof value === 'object' && 'type' in value) { - if (value.type === 'files') { + const typedValue = value as { type: unknown } + if (typedValue.type === 'files') { paths.push(...expandFileTypeProperties(path)) } else { paths.push(path) } } else if (value && typeof value === 'object' && !Array.isArray(value)) { - paths.push(...collectOutputPaths(value, blockType, subBlocks, path)) + paths.push(...collectOutputPaths(value as OutputDefinition, blockType, subBlocks, path)) } else { paths.push(path) } @@ -338,7 +358,7 @@ function collectOutputPaths( export function getBlockOutputPaths( blockType: string, - subBlocks?: Record, + subBlocks?: Record, triggerMode?: boolean ): string[] { const outputs = getBlockOutputs(blockType, subBlocks, triggerMode) @@ -351,39 +371,45 @@ function getFilePropertyType(outputs: OutputDefinition, pathParts: string[]): st return null } - let current: any = outputs + let current: unknown = outputs for (const part of pathParts.slice(0, -1)) { if (!current || typeof current !== 'object') { return null } - current = current[part] + current = (current as Record)[part] } - if (current && typeof current === 'object' && 'type' in current && current.type === 'files') { + if ( + current && + typeof current === 'object' && + 'type' in current && + (current as { type: unknown }).type === 'files' + ) { return USER_FILE_PROPERTY_TYPES[lastPart as keyof typeof USER_FILE_PROPERTY_TYPES] } return null } -function traverseOutputPath(outputs: OutputDefinition, pathParts: string[]): any { - let current: any = outputs +function traverseOutputPath(outputs: OutputDefinition, pathParts: string[]): unknown { + let current: unknown = outputs for (const part of pathParts) { if (!current || typeof current !== 'object') { return null } - current = current[part] + current = (current as Record)[part] } return current } -function extractType(value: any): string { +function extractType(value: unknown): string { if (!value) return 'any' if (typeof value === 'object' && 'type' in value) { - return value.type + const typeValue = (value as { type: unknown }).type + return typeof typeValue === 'string' ? typeValue : 'any' } return typeof value === 'string' ? value : 'any' @@ -392,7 +418,7 @@ function extractType(value: any): string { export function getBlockOutputType( blockType: string, outputPath: string, - subBlocks?: Record, + subBlocks?: Record, triggerMode?: boolean ): string { const outputs = getBlockOutputs(blockType, subBlocks, triggerMode) diff --git a/apps/sim/lib/workflows/comparison/compare.ts b/apps/sim/lib/workflows/comparison/compare.ts index a34521e23b..4f038cd8c2 100644 --- a/apps/sim/lib/workflows/comparison/compare.ts +++ b/apps/sim/lib/workflows/comparison/compare.ts @@ -51,8 +51,8 @@ export function hasWorkflowChanged( } // 3. Build normalized representations of blocks for comparison - const normalizedCurrentBlocks: Record = {} - const normalizedDeployedBlocks: Record = {} + const normalizedCurrentBlocks: Record = {} + const normalizedDeployedBlocks: Record = {} for (const blockId of currentBlockIds) { const currentBlock = currentState.blocks[blockId] @@ -120,8 +120,9 @@ export function hasWorkflowChanged( } // Get values with special handling for null/undefined - let currentValue = currentSubBlocks[subBlockId].value ?? null - let deployedValue = deployedSubBlocks[subBlockId].value ?? null + // Using unknown type since sanitization functions return different types + let currentValue: unknown = currentSubBlocks[subBlockId].value ?? null + let deployedValue: unknown = deployedSubBlocks[subBlockId].value ?? null if (subBlockId === 'tools' && Array.isArray(currentValue) && Array.isArray(deployedValue)) { currentValue = sanitizeTools(currentValue) @@ -232,8 +233,8 @@ export function hasWorkflowChanged( } // 6. Compare variables - const currentVariables = normalizeVariables((currentState as any).variables) - const deployedVariables = normalizeVariables((deployedState as any).variables) + const currentVariables = normalizeVariables(currentState.variables) + const deployedVariables = normalizeVariables(deployedState.variables) const normalizedCurrentVars = normalizeValue( Object.fromEntries(Object.entries(currentVariables).map(([id, v]) => [id, sanitizeVariable(v)])) diff --git a/apps/sim/lib/workflows/comparison/normalize.test.ts b/apps/sim/lib/workflows/comparison/normalize.test.ts index c144694564..ca22205876 100644 --- a/apps/sim/lib/workflows/comparison/normalize.test.ts +++ b/apps/sim/lib/workflows/comparison/normalize.test.ts @@ -2,6 +2,7 @@ * Tests for workflow normalization utilities */ import { describe, expect, it } from 'vitest' +import type { Loop, Parallel } from '@/stores/workflows/workflow/types' import { normalizedStringify, normalizeEdge, @@ -39,7 +40,7 @@ describe('Workflow Normalization Utilities', () => { it.concurrent('should sort object keys alphabetically', () => { const input = { zebra: 1, apple: 2, mango: 3 } - const result = normalizeValue(input) + const result = normalizeValue(input) as Record expect(Object.keys(result)).toEqual(['apple', 'mango', 'zebra']) }) @@ -55,7 +56,10 @@ describe('Workflow Normalization Utilities', () => { }, first: 'value', } - const result = normalizeValue(input) + const result = normalizeValue(input) as { + first: string + outer: { z: number; a: { y: number; b: number } } + } expect(Object.keys(result)).toEqual(['first', 'outer']) expect(Object.keys(result.outer)).toEqual(['a', 'z']) @@ -72,11 +76,11 @@ describe('Workflow Normalization Utilities', () => { it.concurrent('should handle arrays with mixed types', () => { const input = [1, 'string', { b: 2, a: 1 }, null, [3, 2, 1]] - const result = normalizeValue(input) + const result = normalizeValue(input) as unknown[] expect(result[0]).toBe(1) expect(result[1]).toBe('string') - expect(Object.keys(result[2])).toEqual(['a', 'b']) + expect(Object.keys(result[2] as Record)).toEqual(['a', 'b']) expect(result[3]).toBe(null) expect(result[4]).toEqual([3, 2, 1]) // Array order preserved }) @@ -94,7 +98,9 @@ describe('Workflow Normalization Utilities', () => { }, }, } - const result = normalizeValue(input) + const result = normalizeValue(input) as { + level1: { level2: { level3: { level4: { z: string; a: string } } } } + } expect(Object.keys(result.level1.level2.level3.level4)).toEqual(['a', 'z']) }) @@ -143,7 +149,7 @@ describe('Workflow Normalization Utilities', () => { }) it.concurrent('should normalize "for" loop type', () => { - const loop = { + const loop: Loop & { extraField?: string } = { id: 'loop1', nodes: ['block1', 'block2'], loopType: 'for', @@ -164,7 +170,7 @@ describe('Workflow Normalization Utilities', () => { }) it.concurrent('should normalize "forEach" loop type', () => { - const loop = { + const loop: Loop = { id: 'loop2', nodes: ['block1'], loopType: 'forEach', @@ -183,10 +189,11 @@ describe('Workflow Normalization Utilities', () => { }) it.concurrent('should normalize "while" loop type', () => { - const loop = { + const loop: Loop = { id: 'loop3', nodes: ['block1', 'block2', 'block3'], loopType: 'while', + iterations: 0, whileCondition: ' === true', doWhileCondition: 'should-be-excluded', } @@ -201,10 +208,11 @@ describe('Workflow Normalization Utilities', () => { }) it.concurrent('should normalize "doWhile" loop type', () => { - const loop = { + const loop: Loop = { id: 'loop4', nodes: ['block1'], loopType: 'doWhile', + iterations: 0, doWhileCondition: ' < 100', whileCondition: 'should-be-excluded', } @@ -218,11 +226,11 @@ describe('Workflow Normalization Utilities', () => { }) }) - it.concurrent('should handle unknown loop type with base fields only', () => { - const loop = { + it.concurrent('should extract only relevant fields for for loop type', () => { + const loop: Loop = { id: 'loop5', nodes: ['block1'], - loopType: 'unknown', + loopType: 'for', iterations: 5, forEachItems: 'items', } @@ -231,7 +239,8 @@ describe('Workflow Normalization Utilities', () => { expect(result).toEqual({ id: 'loop5', nodes: ['block1'], - loopType: 'unknown', + loopType: 'for', + iterations: 5, }) }) }) @@ -243,7 +252,7 @@ describe('Workflow Normalization Utilities', () => { }) it.concurrent('should normalize "count" parallel type', () => { - const parallel = { + const parallel: Parallel & { extraField?: string } = { id: 'parallel1', nodes: ['block1', 'block2'], parallelType: 'count', @@ -262,7 +271,7 @@ describe('Workflow Normalization Utilities', () => { }) it.concurrent('should normalize "collection" parallel type', () => { - const parallel = { + const parallel: Parallel = { id: 'parallel2', nodes: ['block1'], parallelType: 'collection', @@ -279,11 +288,11 @@ describe('Workflow Normalization Utilities', () => { }) }) - it.concurrent('should handle unknown parallel type with base fields only', () => { - const parallel = { + it.concurrent('should include base fields for undefined parallel type', () => { + const parallel: Parallel = { id: 'parallel3', nodes: ['block1'], - parallelType: 'unknown', + parallelType: undefined, count: 5, distribution: 'items', } @@ -292,7 +301,7 @@ describe('Workflow Normalization Utilities', () => { expect(result).toEqual({ id: 'parallel3', nodes: ['block1'], - parallelType: 'unknown', + parallelType: undefined, }) }) }) @@ -312,7 +321,7 @@ describe('Workflow Normalization Utilities', () => { const tools = [ { id: 'tool1', name: 'Search', isExpanded: true }, { id: 'tool2', name: 'Calculator', isExpanded: false }, - { id: 'tool3', name: 'Weather' }, // No isExpanded field + { id: 'tool3', name: 'Weather' }, ] const result = sanitizeTools(tools) @@ -365,7 +374,7 @@ describe('Workflow Normalization Utilities', () => { const inputFormat = [ { id: 'input1', name: 'Name', value: 'John', collapsed: true }, { id: 'input2', name: 'Age', value: 25, collapsed: false }, - { id: 'input3', name: 'Email' }, // No value or collapsed + { id: 'input3', name: 'Email' }, ] const result = sanitizeInputFormat(inputFormat) diff --git a/apps/sim/lib/workflows/comparison/normalize.ts b/apps/sim/lib/workflows/comparison/normalize.ts index bbc60c81ae..571f201138 100644 --- a/apps/sim/lib/workflows/comparison/normalize.ts +++ b/apps/sim/lib/workflows/comparison/normalize.ts @@ -3,12 +3,15 @@ * Used by both client-side signature computation and server-side comparison. */ +import type { Edge } from 'reactflow' +import type { Loop, Parallel, Variable } from '@/stores/workflows/workflow/types' + /** * Normalizes a value for consistent comparison by sorting object keys recursively * @param value - The value to normalize * @returns A normalized version of the value with sorted keys */ -export function normalizeValue(value: any): any { +export function normalizeValue(value: unknown): unknown { if (value === null || value === undefined || typeof value !== 'object') { return value } @@ -17,9 +20,9 @@ export function normalizeValue(value: any): any { return value.map(normalizeValue) } - const sorted: Record = {} - for (const key of Object.keys(value).sort()) { - sorted[key] = normalizeValue(value[key]) + const sorted: Record = {} + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = normalizeValue((value as Record)[key]) } return sorted } @@ -29,19 +32,30 @@ export function normalizeValue(value: any): any { * @param value - The value to normalize and stringify * @returns A normalized JSON string */ -export function normalizedStringify(value: any): string { +export function normalizedStringify(value: unknown): string { return JSON.stringify(normalizeValue(value)) } +/** Normalized loop result type with only essential fields */ +interface NormalizedLoop { + id: string + nodes: string[] + loopType: Loop['loopType'] + iterations?: number + forEachItems?: Loop['forEachItems'] + whileCondition?: string + doWhileCondition?: string +} + /** * Normalizes a loop configuration by extracting only the relevant fields for the loop type * @param loop - The loop configuration object * @returns Normalized loop with only relevant fields */ -export function normalizeLoop(loop: any): any { +export function normalizeLoop(loop: Loop | null | undefined): NormalizedLoop | null | undefined { if (!loop) return loop const { id, nodes, loopType, iterations, forEachItems, whileCondition, doWhileCondition } = loop - const base: any = { id, nodes, loopType } + const base: Pick = { id, nodes, loopType } switch (loopType) { case 'for': @@ -57,15 +71,30 @@ export function normalizeLoop(loop: any): any { } } +/** Normalized parallel result type with only essential fields */ +interface NormalizedParallel { + id: string + nodes: string[] + parallelType: Parallel['parallelType'] + count?: number + distribution?: Parallel['distribution'] +} + /** * Normalizes a parallel configuration by extracting only the relevant fields for the parallel type * @param parallel - The parallel configuration object * @returns Normalized parallel with only relevant fields */ -export function normalizeParallel(parallel: any): any { +export function normalizeParallel( + parallel: Parallel | null | undefined +): NormalizedParallel | null | undefined { if (!parallel) return parallel const { id, nodes, parallelType, count, distribution } = parallel - const base: any = { id, nodes, parallelType } + const base: Pick = { + id, + nodes, + parallelType, + } switch (parallelType) { case 'count': @@ -77,23 +106,37 @@ export function normalizeParallel(parallel: any): any { } } +/** Tool configuration with optional UI-only isExpanded field */ +type ToolWithExpanded = Record & { isExpanded?: boolean } + /** * Sanitizes tools array by removing UI-only fields like isExpanded * @param tools - Array of tool configurations * @returns Sanitized tools array */ -export function sanitizeTools(tools: any[] | undefined): any[] { +export function sanitizeTools(tools: unknown[] | undefined): Record[] { if (!Array.isArray(tools)) return [] - return tools.map(({ isExpanded, ...rest }) => rest) + return tools.map((tool) => { + if (tool && typeof tool === 'object' && !Array.isArray(tool)) { + const { isExpanded, ...rest } = tool as ToolWithExpanded + return rest + } + return tool as Record + }) } +/** Variable with optional UI-only validationError field */ +type VariableWithValidation = Variable & { validationError?: string } + /** * Sanitizes a variable by removing UI-only fields like validationError * @param variable - The variable object * @returns Sanitized variable object */ -export function sanitizeVariable(variable: any): any { +export function sanitizeVariable( + variable: VariableWithValidation | null | undefined +): Omit | null | undefined { if (!variable || typeof variable !== 'object') return variable const { validationError, ...rest } = variable return rest @@ -105,21 +148,38 @@ export function sanitizeVariable(variable: any): any { * @param variables - The variables to normalize * @returns A normalized variables object */ -export function normalizeVariables(variables: any): Record { +export function normalizeVariables(variables: unknown): Record { if (!variables) return {} if (Array.isArray(variables)) return {} if (typeof variables !== 'object') return {} - return variables + return variables as Record } +/** Input format item with optional UI-only fields */ +type InputFormatItem = Record & { value?: unknown; collapsed?: boolean } + /** * Sanitizes inputFormat array by removing UI-only fields like value and collapsed * @param inputFormat - Array of input format configurations * @returns Sanitized input format array */ -export function sanitizeInputFormat(inputFormat: any[] | undefined): any[] { +export function sanitizeInputFormat(inputFormat: unknown[] | undefined): Record[] { if (!Array.isArray(inputFormat)) return [] - return inputFormat.map(({ value, collapsed, ...rest }) => rest) + return inputFormat.map((item) => { + if (item && typeof item === 'object' && !Array.isArray(item)) { + const { value, collapsed, ...rest } = item as InputFormatItem + return rest + } + return item as Record + }) +} + +/** Normalized edge with only connection-relevant fields */ +interface NormalizedEdge { + source: string + sourceHandle?: string | null + target: string + targetHandle?: string | null } /** @@ -127,12 +187,7 @@ export function sanitizeInputFormat(inputFormat: any[] | undefined): any[] { * @param edge - The edge object * @returns Normalized edge with only connection fields */ -export function normalizeEdge(edge: any): { - source: string - sourceHandle?: string - target: string - targetHandle?: string -} { +export function normalizeEdge(edge: Edge): NormalizedEdge { return { source: edge.source, sourceHandle: edge.sourceHandle, @@ -147,8 +202,18 @@ export function normalizeEdge(edge: any): { * @returns Sorted array of normalized edges */ export function sortEdges( - edges: Array<{ source: string; sourceHandle?: string; target: string; targetHandle?: string }> -): Array<{ source: string; sourceHandle?: string; target: string; targetHandle?: string }> { + edges: Array<{ + source: string + sourceHandle?: string | null + target: string + targetHandle?: string | null + }> +): Array<{ + source: string + sourceHandle?: string | null + target: string + targetHandle?: string | null +}> { return [...edges].sort((a, b) => `${a.source}-${a.sourceHandle}-${a.target}-${a.targetHandle}`.localeCompare( `${b.source}-${b.sourceHandle}-${b.target}-${b.targetHandle}` diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index 014febabc5..2fb757ba49 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -1,6 +1,15 @@ import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { AuthMode } from '@/blocks/types' +import type { BlockState, SubBlockState, WorkflowState } from '@/stores/workflows/workflow/types' + +/** Condition type for SubBlock visibility - mirrors the inline type from blocks/types.ts */ +interface SubBlockCondition { + field: string + value: string | number | boolean | Array | undefined + not?: boolean + and?: SubBlockCondition +} // Credential types based on actual patterns in the codebase export enum CredentialType { @@ -48,7 +57,9 @@ const WORKSPACE_SPECIFIC_FIELDS = new Set([ * Extract required credentials from a workflow state * This analyzes all blocks and their subblocks to identify credential requirements */ -export function extractRequiredCredentials(state: any): CredentialRequirement[] { +export function extractRequiredCredentials( + state: Partial | null | undefined +): CredentialRequirement[] { const credentials: CredentialRequirement[] = [] const seen = new Set() @@ -57,7 +68,7 @@ export function extractRequiredCredentials(state: any): CredentialRequirement[] } // Process each block - Object.values(state.blocks).forEach((block: any) => { + Object.values(state.blocks).forEach((block: BlockState) => { if (!block?.type) return const blockConfig = getBlock(block.type) @@ -104,8 +115,8 @@ export function extractRequiredCredentials(state: any): CredentialRequirement[] }) }) - // Helper to check visibility, respecting mode and conditions - function isSubBlockVisible(block: any, subBlockConfig: SubBlockConfig): boolean { + /** Helper to check visibility, respecting mode and conditions */ + function isSubBlockVisible(block: BlockState, subBlockConfig: SubBlockConfig): boolean { const mode = subBlockConfig.mode ?? 'both' if (mode === 'trigger' && !block?.triggerMode) return false if (mode === 'basic' && block?.advancedMode) return false @@ -118,7 +129,7 @@ export function extractRequiredCredentials(state: any): CredentialRequirement[] ? subBlockConfig.condition() : subBlockConfig.condition - const evaluate = (cond: any): boolean => { + const evaluate = (cond: SubBlockCondition): boolean => { const currentValue = block?.subBlocks?.[cond.field]?.value const expected = cond.value @@ -126,7 +137,7 @@ export function extractRequiredCredentials(state: any): CredentialRequirement[] expected === undefined ? true : Array.isArray(expected) - ? expected.includes(currentValue) + ? expected.includes(currentValue as string) : currentValue === expected if (cond.not) match = !match @@ -161,6 +172,12 @@ function formatFieldName(fieldName: string): string { .join(' ') } +/** Block state with mutable subBlocks for sanitization */ +interface MutableBlockState extends Omit { + subBlocks: Record + data?: Record +} + /** * Remove malformed subBlocks from a block that may have been created by bugs. * This includes subBlocks with: @@ -168,12 +185,12 @@ function formatFieldName(fieldName: string): string { * - Missing required `id` field * - Type "unknown" (indicates malformed data) */ -function removeMalformedSubBlocks(block: any): void { +function removeMalformedSubBlocks(block: MutableBlockState): void { if (!block.subBlocks) return const keysToRemove: string[] = [] - Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => { + Object.entries(block.subBlocks).forEach(([key, subBlock]) => { // Flag subBlocks with invalid keys (literal "undefined" string) if (key === 'undefined') { keysToRemove.push(key) @@ -187,7 +204,8 @@ function removeMalformedSubBlocks(block: any): void { } // Flag subBlocks with type "unknown" (malformed data) - if (subBlock.type === 'unknown') { + // Cast to string for comparison since SubBlockType doesn't include 'unknown' + if ((subBlock.type as string) === 'unknown') { keysToRemove.push(key) return } @@ -204,6 +222,12 @@ function removeMalformedSubBlocks(block: any): void { }) } +/** Sanitized workflow state structure */ +interface SanitizedWorkflowState { + blocks?: Record + [key: string]: unknown +} + /** * Sanitize workflow state by removing all credentials and workspace-specific data * This is used for both template creation and workflow export to ensure consistency @@ -212,18 +236,18 @@ function removeMalformedSubBlocks(block: any): void { * @param options - Options for sanitization behavior */ export function sanitizeWorkflowForSharing( - state: any, + state: Partial | null | undefined, options: { preserveEnvVars?: boolean // Keep {{VAR}} references for export } = {} -): any { - const sanitized = JSON.parse(JSON.stringify(state)) // Deep clone +): SanitizedWorkflowState { + const sanitized = JSON.parse(JSON.stringify(state)) as SanitizedWorkflowState // Deep clone if (!sanitized?.blocks) { return sanitized } - Object.values(sanitized.blocks).forEach((block: any) => { + Object.values(sanitized.blocks).forEach((block: MutableBlockState) => { if (!block?.type) return // First, remove any malformed subBlocks that may have been created by bugs @@ -239,7 +263,7 @@ export function sanitizeWorkflowForSharing( // Clear OAuth credentials (type: 'oauth-input') if (subBlockConfig.type === 'oauth-input') { - block.subBlocks[subBlockConfig.id].value = null + block.subBlocks[subBlockConfig.id]!.value = null } // Clear secret fields (password: true) @@ -247,24 +271,24 @@ export function sanitizeWorkflowForSharing( // Preserve environment variable references if requested if ( options.preserveEnvVars && - typeof subBlock.value === 'string' && + typeof subBlock?.value === 'string' && subBlock.value.startsWith('{{') && subBlock.value.endsWith('}}') ) { // Keep the env var reference } else { - block.subBlocks[subBlockConfig.id].value = null + block.subBlocks[subBlockConfig.id]!.value = null } } // Clear workspace-specific selectors else if (WORKSPACE_SPECIFIC_TYPES.has(subBlockConfig.type)) { - block.subBlocks[subBlockConfig.id].value = null + block.subBlocks[subBlockConfig.id]!.value = null } // Clear workspace-specific fields by ID else if (WORKSPACE_SPECIFIC_FIELDS.has(subBlockConfig.id)) { - block.subBlocks[subBlockConfig.id].value = null + block.subBlocks[subBlockConfig.id]!.value = null } } }) @@ -272,9 +296,9 @@ export function sanitizeWorkflowForSharing( // Process subBlocks without config (fallback) if (block.subBlocks) { - Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => { + Object.entries(block.subBlocks).forEach(([key, subBlock]) => { // Clear workspace-specific fields by key name - if (WORKSPACE_SPECIFIC_FIELDS.has(key)) { + if (WORKSPACE_SPECIFIC_FIELDS.has(key) && subBlock) { subBlock.value = null } }) @@ -282,14 +306,14 @@ export function sanitizeWorkflowForSharing( // Clear data field (for backward compatibility) if (block.data) { - Object.entries(block.data).forEach(([key, value]: [string, any]) => { + Object.entries(block.data).forEach(([key]) => { // Clear anything that looks like credentials if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) { - block.data[key] = null + block.data![key] = null } // Clear workspace-specific data if (WORKSPACE_SPECIFIC_FIELDS.has(key)) { - block.data[key] = null + block.data![key] = null } }) } @@ -302,7 +326,9 @@ export function sanitizeWorkflowForSharing( * Sanitize workflow state for templates (removes credentials and workspace data) * Wrapper for backward compatibility */ -export function sanitizeCredentials(state: any): any { +export function sanitizeCredentials( + state: Partial | null | undefined +): SanitizedWorkflowState { return sanitizeWorkflowForSharing(state, { preserveEnvVars: false }) } @@ -310,6 +336,8 @@ export function sanitizeCredentials(state: any): any { * Sanitize workflow state for export (preserves env vars) * Convenience wrapper for workflow export */ -export function sanitizeForExport(state: any): any { +export function sanitizeForExport( + state: Partial | null | undefined +): SanitizedWorkflowState { return sanitizeWorkflowForSharing(state, { preserveEnvVars: true }) } diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index 3efb6831ae..f22365d145 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -245,10 +245,10 @@ function computeFieldDiff( const unchangedFields: string[] = [] // Check basic fields - const fieldsToCheck = ['type', 'name', 'enabled', 'triggerMode', 'horizontalHandles'] + const fieldsToCheck = ['type', 'name', 'enabled', 'triggerMode', 'horizontalHandles'] as const for (const field of fieldsToCheck) { - const currentValue = (currentBlock as any)[field] - const proposedValue = (proposedBlock as any)[field] + const currentValue = currentBlock[field] + const proposedValue = proposedBlock[field] if (JSON.stringify(currentValue) !== JSON.stringify(proposedValue)) { changedFields.push(field) } else if (currentValue !== undefined) { @@ -363,7 +363,7 @@ export class WorkflowDiffEngine { } // Call the API route to create the diff - const body: any = { + const body: Record = { jsonContent, currentWorkflowState: mergedBaseline, } @@ -859,7 +859,7 @@ export class WorkflowDiffEngine { const proposedEdgeSet = new Set() // Create edge identifiers for current state (using sim-agent format) - mergedBaseline.edges.forEach((edge: any) => { + mergedBaseline.edges.forEach((edge: Edge) => { const edgeId = `${edge.source}-${edge.sourceHandle || 'source'}-${edge.target}-${edge.targetHandle || 'target'}` currentEdgeSet.add(edgeId) }) @@ -992,7 +992,7 @@ export class WorkflowDiffEngine { } // Call the API route to merge the diff - const body: any = { + const body: Record = { existingDiff: this.currentDiff, jsonContent, } diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index b16e6ea820..ce6f4c2c0d 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -5,6 +5,7 @@ import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' +import type { ExecutionResult, StreamingExecution } from '@/executor/types' const logger = createLogger('WorkflowExecution') @@ -13,8 +14,8 @@ export interface ExecuteWorkflowOptions { selectedOutputs?: string[] isSecureMode?: boolean workflowTriggerType?: 'api' | 'chat' - onStream?: (streamingExec: any) => Promise - onBlockComplete?: (blockId: string, output: any) => Promise + onStream?: (streamingExec: StreamingExecution) => Promise + onBlockComplete?: (blockId: string, output: unknown) => Promise skipLoggingComplete?: boolean } @@ -29,11 +30,11 @@ export interface WorkflowInfo { export async function executeWorkflow( workflow: WorkflowInfo, requestId: string, - input: any | undefined, + input: unknown | undefined, actorUserId: string, streamConfig?: ExecuteWorkflowOptions, providedExecutionId?: string -): Promise { +): Promise { if (!workflow.workspaceId) { throw new Error(`Workflow ${workflow.id} has no workspaceId`) } @@ -71,7 +72,7 @@ export async function executeWorkflow( callbacks: { onStream: streamConfig?.onStream, onBlockComplete: streamConfig?.onBlockComplete - ? async (blockId: string, _blockName: string, _blockType: string, output: any) => { + ? async (blockId: string, _blockName: string, _blockType: string, output: unknown) => { await streamConfig.onBlockComplete!(blockId, output) } : undefined, @@ -119,7 +120,7 @@ export async function executeWorkflow( } return result - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${requestId}] Workflow execution failed:`, error) throw error } diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index 9e81d8711a..0eeb946542 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -19,8 +19,12 @@ import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { Executor } from '@/executor' import { REFERENCE } from '@/executor/constants' import type { ExecutionSnapshot } from '@/executor/execution/snapshot' -import type { ExecutionCallbacks, IterationContext } from '@/executor/execution/types' -import type { ExecutionResult } from '@/executor/types' +import type { + ContextExtensions, + ExecutionCallbacks, + IterationContext, +} from '@/executor/execution/types' +import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types' import { createEnvVarPattern } from '@/executor/utils/reference-validation' import { Serializer } from '@/serializer' import { mergeSubblockState } from '@/stores/workflows/server-utils' @@ -41,7 +45,7 @@ export interface ExecuteWorkflowCoreOptions { abortSignal?: AbortSignal } -function parseVariableValueByType(value: any, type: string): any { +function parseVariableValueByType(value: unknown, type: string): unknown { if (value === null || value === undefined) { switch (type) { case 'number': @@ -262,7 +266,7 @@ export async function executeWorkflowCore( const filteredEdges = edges // Check if this is a resume execution before trigger resolution - const resumeFromSnapshot = (metadata as any).resumeFromSnapshot === true + const resumeFromSnapshot = metadata.resumeFromSnapshot === true const resumePendingQueue = snapshot.state?.pendingQueue let resolvedTriggerBlockId = triggerBlockId @@ -321,7 +325,7 @@ export async function executeWorkflowCore( blockId: string, blockName: string, blockType: string, - output: any, + output: { input?: unknown; output: NormalizedBlockOutput; executionTime: number }, iterationContext?: IterationContext ) => { await loggingSession.onBlockComplete(blockId, blockName, blockType, output) @@ -330,7 +334,7 @@ export async function executeWorkflowCore( } } - const contextExtensions: any = { + const contextExtensions: ContextExtensions = { stream: !!onStream, selectedOutputs, executionId, @@ -342,7 +346,12 @@ export async function executeWorkflowCore( onStream, resumeFromSnapshot, resumePendingQueue, - remainingEdges: snapshot.state?.remainingEdges, + remainingEdges: snapshot.state?.remainingEdges?.map((edge) => ({ + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle ?? undefined, + targetHandle: edge.targetHandle ?? undefined, + })), dagIncomingEdges: snapshot.state?.dagIncomingEdges, snapshotState: snapshot.state, metadata, @@ -363,7 +372,7 @@ export async function executeWorkflowCore( // Convert initial workflow variables to their native types if (workflowVariables) { for (const [varId, variable] of Object.entries(workflowVariables)) { - const v = variable as any + const v = variable as { value?: unknown; type?: string } if (v.value !== undefined && v.type) { v.value = parseVariableValueByType(v.value, v.type) } @@ -432,18 +441,23 @@ export async function executeWorkflowCore( }) return result - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${requestId}] Execution failed:`, error) - const executionResult = (error as any)?.executionResult + const errorWithResult = error as { + executionResult?: ExecutionResult + message?: string + stack?: string + } + const executionResult = errorWithResult?.executionResult const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] } await loggingSession.safeCompleteWithError({ endedAt: new Date().toISOString(), totalDurationMs: executionResult?.metadata?.duration || 0, error: { - message: error.message || 'Execution failed', - stackTrace: error.stack, + message: errorWithResult?.message || 'Execution failed', + stackTrace: errorWithResult?.stack, }, traceSpans, }) diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 5c10de594b..f695e8dc69 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -2,13 +2,14 @@ import { randomUUID } from 'crypto' import { db } from '@sim/db' import { pausedExecutions, resumeQueue, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, asc, desc, eq, inArray, lt, sql } from 'drizzle-orm' +import { and, asc, desc, eq, inArray, lt, type SQL, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionResult, PausePoint, SerializedSnapshot } from '@/executor/types' +import type { SerializedConnection } from '@/serializer/types' const logger = createLogger('HumanInTheLoopManager') @@ -18,7 +19,7 @@ interface ResumeQueueEntrySummary { parentExecutionId: string newExecutionId: string contextId: string - resumeInput: any + resumeInput: unknown status: string queuedAt: string | null claimedAt: string | null @@ -69,7 +70,7 @@ interface PersistPauseResultArgs { interface EnqueueResumeArgs { executionId: string contextId: string - resumeInput: any + resumeInput: unknown userId: string } @@ -85,7 +86,7 @@ type EnqueueResumeResult = resumeEntryId: string pausedExecution: typeof pausedExecutions.$inferSelect contextId: string - resumeInput: any + resumeInput: unknown userId: string } @@ -94,7 +95,7 @@ interface StartResumeExecutionArgs { resumeExecutionId: string pausedExecution: typeof pausedExecutions.$inferSelect contextId: string - resumeInput: any + resumeInput: unknown userId: string } @@ -365,7 +366,7 @@ export class PauseResumeManager { resumeExecutionId: string pausedExecution: typeof pausedExecutions.$inferSelect contextId: string - resumeInput: any + resumeInput: unknown userId: string }): Promise { const { resumeExecutionId, pausedExecution, contextId, resumeInput, userId } = args @@ -408,9 +409,8 @@ export class PauseResumeManager { const rawPauseBlockId = pausePoint.blockId ?? contextId const pauseBlockId = PauseResumeManager.normalizePauseBlockId(rawPauseBlockId) - const dagIncomingEdgesFromSnapshot: Record | undefined = ( - baseSnapshot.state as any - )?.dagIncomingEdges + const dagIncomingEdgesFromSnapshot: Record | undefined = + baseSnapshot.state?.dagIncomingEdges const downstreamBlocks = dagIncomingEdgesFromSnapshot ? Object.entries(dagIncomingEdgesFromSnapshot) @@ -424,9 +424,10 @@ export class PauseResumeManager { .map(([nodeId]) => nodeId) : baseSnapshot.workflow.connections .filter( - (conn: any) => PauseResumeManager.normalizePauseBlockId(conn.source) === pauseBlockId + (conn: SerializedConnection) => + PauseResumeManager.normalizePauseBlockId(conn.source) === pauseBlockId ) - .map((conn: any) => conn.target) + .map((conn: SerializedConnection) => conn.target) logger.info('Found downstream blocks', { pauseBlockId, @@ -448,7 +449,7 @@ export class PauseResumeManager { if (stateCopy) { const dagIncomingEdges: Record | undefined = - (stateCopy as any)?.dagIncomingEdges || dagIncomingEdgesFromSnapshot + stateCopy.dagIncomingEdges || dagIncomingEdgesFromSnapshot // Calculate the pause duration (time from pause to resume) const pauseDurationMs = pausedExecution.pausedAt @@ -617,11 +618,11 @@ export class PauseResumeManager { // If we didn't find any edges via the DAG snapshot, fall back to workflow connections if (edgesToRemove.length === 0 && baseSnapshot.workflow.connections?.length) { edgesToRemove = baseSnapshot.workflow.connections - .filter((conn: any) => + .filter((conn: SerializedConnection) => completedPauseContexts.has(PauseResumeManager.normalizePauseBlockId(conn.source)) ) - .map((conn: any) => ({ - id: conn.id ?? `${conn.source}→${conn.target}`, + .map((conn: SerializedConnection) => ({ + id: `${conn.source}→${conn.target}`, source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle, @@ -630,11 +631,11 @@ export class PauseResumeManager { } } else { edgesToRemove = baseSnapshot.workflow.connections - .filter((conn: any) => + .filter((conn: SerializedConnection) => completedPauseContexts.has(PauseResumeManager.normalizePauseBlockId(conn.source)) ) - .map((conn: any) => ({ - id: conn.id ?? `${conn.source}→${conn.target}`, + .map((conn: SerializedConnection) => ({ + id: `${conn.source}→${conn.target}`, source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle, @@ -913,7 +914,7 @@ export class PauseResumeManager { }): Promise { const { workflowId, status } = options - let whereClause: any = eq(pausedExecutions.workflowId, workflowId) + let whereClause: SQL | undefined = eq(pausedExecutions.workflowId, workflowId) if (status) { const statuses = Array.isArray(status) @@ -924,7 +925,7 @@ export class PauseResumeManager { if (statuses.length === 1) { whereClause = and(whereClause, eq(pausedExecutions.status, statuses[0])) } else if (statuses.length > 1) { - whereClause = and(whereClause, inArray(pausedExecutions.status, statuses as any)) + whereClause = and(whereClause, inArray(pausedExecutions.status, statuses)) } } @@ -1129,16 +1130,16 @@ export class PauseResumeManager { } private static mapPausePoints( - pausePoints: any, + pausePoints: unknown, queuePositions?: Map, latestEntries?: Map ): PausePointWithQueue[] { - const record = pausePoints as Record + const record = pausePoints as Record | null if (!record) { return [] } - return Object.values(record).map((point: any) => { + return Object.values(record).map((point: PausePoint) => { const queuePosition = queuePositions?.get(point.contextId ?? '') ?? null const latestEntry = latestEntries?.get(point.contextId ?? '') diff --git a/apps/sim/lib/workflows/operations/import-export.ts b/apps/sim/lib/workflows/operations/import-export.ts index f5dbd52a92..b446ea1083 100644 --- a/apps/sim/lib/workflows/operations/import-export.ts +++ b/apps/sim/lib/workflows/operations/import-export.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import JSZip from 'jszip' import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer' -import type { WorkflowState } from '@/stores/workflows/workflow/types' +import type { Variable, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowImportExport') @@ -14,12 +14,7 @@ export interface WorkflowExportData { folderId?: string | null } state: WorkflowState - variables?: Array<{ - id: string - name: string - type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' - value: any - }> + variables?: Record } export interface FolderExportData { diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index b115321202..6eb55be247 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -9,7 +9,7 @@ import { workflowSubflows, } from '@sim/db' import { createLogger } from '@sim/logger' -import type { InferSelectModel } from 'drizzle-orm' +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' import { and, desc, eq, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' import { v4 as uuidv4 } from 'uuid' @@ -22,6 +22,8 @@ import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/w const logger = createLogger('WorkflowDBHelpers') export type WorkflowDeploymentVersion = InferSelectModel +type WebhookRecord = InferSelectModel +type SubflowInsert = InferInsertModel export interface WorkflowDeploymentVersionResponse { id: string @@ -43,7 +45,7 @@ export interface NormalizedWorkflowData { export interface DeployedWorkflowData extends NormalizedWorkflowData { deploymentVersionId: string - variables?: Record + variables?: Record } export async function blockExistsInDeployment( @@ -96,7 +98,7 @@ export async function loadDeployedWorkflowState(workflowId: string): Promise } + const state = active.state as WorkflowState & { variables?: Record } return { blocks: state.blocks || {}, @@ -336,7 +338,7 @@ export async function saveWorkflowToNormalizedTables( // Start a transaction await db.transaction(async (tx) => { // Snapshot existing webhooks before deletion to preserve them through the cycle - let existingWebhooks: any[] = [] + let existingWebhooks: WebhookRecord[] = [] try { existingWebhooks = await tx.select().from(webhook).where(eq(webhook.workflowId, workflowId)) } catch (webhookError) { @@ -392,7 +394,7 @@ export async function saveWorkflowToNormalizedTables( } // Insert subflows (loops and parallels) - const subflowInserts: any[] = [] + const subflowInserts: SubflowInsert[] = [] // Add loops Object.values(canonicalLoops).forEach((loop) => { @@ -571,7 +573,7 @@ export async function deployWorkflow(params: { const blockTypeCounts: Record = {} for (const block of Object.values(currentState.blocks)) { - const blockType = (block as any).type || 'unknown' + const blockType = block.type || 'unknown' blockTypeCounts[blockType] = (blockTypeCounts[blockType] || 0) + 1 } @@ -605,11 +607,33 @@ export async function deployWorkflow(params: { } } +/** Input state for ID regeneration - partial to handle external sources */ +interface RegenerateStateInput { + blocks?: Record + edges?: Edge[] + loops?: Record + parallels?: Record + lastSaved?: number + variables?: Record + metadata?: Record +} + +/** Output state after ID regeneration */ +interface RegenerateStateOutput { + blocks: Record + edges: Edge[] + loops: Record + parallels: Record + lastSaved: number + variables?: Record + metadata?: Record +} + /** * Regenerates all IDs in a workflow state to avoid conflicts when duplicating or using templates * Returns a new state with all IDs regenerated and references updated */ -export function regenerateWorkflowStateIds(state: any): any { +export function regenerateWorkflowStateIds(state: RegenerateStateInput): RegenerateStateOutput { // Create ID mappings const blockIdMapping = new Map() const edgeIdMapping = new Map() @@ -624,7 +648,7 @@ export function regenerateWorkflowStateIds(state: any): any { // Map edge IDs - ;(state.edges || []).forEach((edge: any) => { + ;(state.edges || []).forEach((edge: Edge) => { edgeIdMapping.set(edge.id, crypto.randomUUID()) }) @@ -639,28 +663,28 @@ export function regenerateWorkflowStateIds(state: any): any { }) // Second pass: Create new state with regenerated IDs and updated references - const newBlocks: Record = {} - const newEdges: any[] = [] - const newLoops: Record = {} - const newParallels: Record = {} + const newBlocks: Record = {} + const newEdges: Edge[] = [] + const newLoops: Record = {} + const newParallels: Record = {} // Regenerate blocks with updated references - Object.entries(state.blocks || {}).forEach(([oldId, block]: [string, any]) => { + Object.entries(state.blocks || {}).forEach(([oldId, block]) => { const newId = blockIdMapping.get(oldId)! - const newBlock = { ...block, id: newId } + const newBlock: BlockState = { ...block, id: newId } // Update parentId reference if it exists if (newBlock.data?.parentId) { const newParentId = blockIdMapping.get(newBlock.data.parentId) if (newParentId) { - newBlock.data.parentId = newParentId + newBlock.data = { ...newBlock.data, parentId: newParentId } } } // Update any block references in subBlocks if (newBlock.subBlocks) { - const updatedSubBlocks: Record = {} - Object.entries(newBlock.subBlocks).forEach(([subId, subBlock]: [string, any]) => { + const updatedSubBlocks: Record = {} + Object.entries(newBlock.subBlocks).forEach(([subId, subBlock]) => { const updatedSubBlock = { ...subBlock } // If subblock value contains block references, update them @@ -668,7 +692,7 @@ export function regenerateWorkflowStateIds(state: any): any { typeof updatedSubBlock.value === 'string' && blockIdMapping.has(updatedSubBlock.value) ) { - updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value) + updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value) ?? updatedSubBlock.value } updatedSubBlocks[subId] = updatedSubBlock @@ -681,7 +705,7 @@ export function regenerateWorkflowStateIds(state: any): any { // Regenerate edges with updated source/target references - ;(state.edges || []).forEach((edge: any) => { + ;(state.edges || []).forEach((edge: Edge) => { const newId = edgeIdMapping.get(edge.id)! const newSource = blockIdMapping.get(edge.source) || edge.source const newTarget = blockIdMapping.get(edge.target) || edge.target @@ -695,9 +719,9 @@ export function regenerateWorkflowStateIds(state: any): any { }) // Regenerate loops with updated node references - Object.entries(state.loops || {}).forEach(([oldId, loop]: [string, any]) => { + Object.entries(state.loops || {}).forEach(([oldId, loop]) => { const newId = loopIdMapping.get(oldId)! - const newLoop = { ...loop, id: newId } + const newLoop: Loop = { ...loop, id: newId } // Update nodes array with new block IDs if (newLoop.nodes) { @@ -708,9 +732,9 @@ export function regenerateWorkflowStateIds(state: any): any { }) // Regenerate parallels with updated node references - Object.entries(state.parallels || {}).forEach(([oldId, parallel]: [string, any]) => { + Object.entries(state.parallels || {}).forEach(([oldId, parallel]) => { const newId = parallelIdMapping.get(oldId)! - const newParallel = { ...parallel, id: newId } + const newParallel: Parallel = { ...parallel, id: newId } // Update nodes array with new block IDs if (newParallel.nodes) { diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index eb062599f0..8ee5b01957 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -59,26 +59,36 @@ export interface ExportWorkflowState { id: string name: string type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' - value: any + value: unknown }> } } +/** Condition structure for sanitization */ +interface SanitizedCondition { + id: string + title: string + value: string +} + /** * Sanitize condition blocks by removing UI-specific metadata * Returns cleaned JSON string (not parsed array) */ function sanitizeConditions(conditionsJson: string): string { try { - const conditions = JSON.parse(conditionsJson) + const conditions: unknown = JSON.parse(conditionsJson) if (!Array.isArray(conditions)) return conditionsJson // Keep only id, title, and value - remove UI state - const cleaned = conditions.map((cond: any) => ({ - id: cond.id, - title: cond.title, - value: cond.value || '', - })) + const cleaned: SanitizedCondition[] = conditions.map((cond: unknown) => { + const condition = cond as Record + return { + id: String(condition.id ?? ''), + title: String(condition.title ?? ''), + value: String(condition.value ?? ''), + } + }) return JSON.stringify(cleaned) } catch { @@ -86,11 +96,50 @@ function sanitizeConditions(conditionsJson: string): string { } } +/** Tool input structure for sanitization */ +interface ToolInput { + type: string + customToolId?: string + schema?: { + type?: string + function?: { + name: string + description?: string + parameters?: unknown + } + } + code?: string + title?: string + toolId?: string + usageControl?: string + isExpanded?: boolean + [key: string]: unknown +} + +/** Sanitized tool output structure */ +interface SanitizedTool { + type: string + customToolId?: string + usageControl?: string + title?: string + toolId?: string + schema?: { + type: string + function: { + name: string + description?: string + parameters?: unknown + } + } + code?: string + [key: string]: unknown +} + /** * Sanitize tools array by removing UI state and redundant fields */ -function sanitizeTools(tools: any[]): any[] { - return tools.map((tool) => { +function sanitizeTools(tools: ToolInput[]): SanitizedTool[] { + return tools.map((tool): SanitizedTool => { if (tool.type === 'custom-tool') { // New reference format: minimal fields only if (tool.customToolId && !tool.schema && !tool.code) { @@ -102,7 +151,7 @@ function sanitizeTools(tools: any[]): any[] { } // Legacy inline format: include all fields - const sanitized: any = { + const sanitized: SanitizedTool = { type: tool.type, title: tool.title, toolId: tool.toolId, @@ -129,23 +178,24 @@ function sanitizeTools(tools: any[]): any[] { return sanitized } - const { isExpanded, ...cleanTool } = tool - return cleanTool + const { isExpanded: _isExpanded, ...cleanTool } = tool + return cleanTool as SanitizedTool }) } /** * Sort object keys recursively for consistent comparison */ -function sortKeysRecursively(item: any): any { +function sortKeysRecursively(item: unknown): unknown { if (Array.isArray(item)) { return item.map(sortKeysRecursively) } if (item !== null && typeof item === 'object') { - return Object.keys(item) + const obj = item as Record + return Object.keys(obj) .sort() - .reduce((result: any, key: string) => { - result[key] = sortKeysRecursively(item[key]) + .reduce((result: Record, key: string) => { + result[key] = sortKeysRecursively(obj[key]) return result }, {}) } @@ -183,7 +233,7 @@ function sanitizeSubBlocks( // Sort keys for consistent comparison if (obj && typeof obj === 'object') { - sanitized[key] = sortKeysRecursively(obj) + sanitized[key] = sortKeysRecursively(obj) as Record return } } catch { @@ -201,7 +251,7 @@ function sanitizeSubBlocks( } if (key === 'tools' && Array.isArray(subBlock.value)) { - sanitized[key] = sanitizeTools(subBlock.value) + sanitized[key] = sanitizeTools(subBlock.value as unknown as ToolInput[]) return } @@ -383,7 +433,7 @@ export function sanitizeForExport(state: WorkflowState): ExportWorkflowState { // Use unified sanitization with env var preservation for export const sanitizedState = sanitizeWorkflowForSharing(fullState, { preserveEnvVars: true, // Keep {{ENV_VAR}} references in exported workflows - }) + }) as ExportWorkflowState['state'] return { version: '1.0', diff --git a/apps/sim/lib/workflows/sanitization/validation.ts b/apps/sim/lib/workflows/sanitization/validation.ts index 75e9ef5639..4c25d19981 100644 --- a/apps/sim/lib/workflows/sanitization/validation.ts +++ b/apps/sim/lib/workflows/sanitization/validation.ts @@ -1,20 +1,40 @@ import { createLogger } from '@sim/logger' import { getBlock } from '@/blocks/registry' import { isCustomTool, isMcpTool } from '@/executor/constants' -import type { WorkflowState } from '@/stores/workflows/workflow/types' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' import { getTool } from '@/tools/utils' const logger = createLogger('WorkflowValidation') +/** Tool structure for validation */ +interface AgentTool { + type: string + customToolId?: string + schema?: { + type?: string + function?: { + name?: string + parameters?: { + type?: string + properties?: Record + } + } + } + code?: string + usageControl?: string + [key: string]: unknown +} + /** * Checks if a custom tool has a valid inline schema */ -function isValidCustomToolSchema(tool: any): boolean { +function isValidCustomToolSchema(tool: unknown): boolean { try { if (!tool || typeof tool !== 'object') return false - if (tool.type !== 'custom-tool') return true // non-custom tools are validated elsewhere + const t = tool as AgentTool + if (t.type !== 'custom-tool') return true // non-custom tools are validated elsewhere - const schema = tool.schema + const schema = t.schema if (!schema || typeof schema !== 'object') return false const fn = schema.function if (!fn || typeof fn !== 'object') return false @@ -34,14 +54,15 @@ function isValidCustomToolSchema(tool: any): boolean { /** * Checks if a custom tool is a valid reference-only format (new format) */ -function isValidCustomToolReference(tool: any): boolean { +function isValidCustomToolReference(tool: unknown): boolean { try { if (!tool || typeof tool !== 'object') return false - if (tool.type !== 'custom-tool') return false + const t = tool as AgentTool + if (t.type !== 'custom-tool') return false // Reference format: has customToolId but no inline schema/code // This is valid - the tool will be loaded dynamically during execution - if (tool.customToolId && typeof tool.customToolId === 'string') { + if (t.customToolId && typeof t.customToolId === 'string') { return true } @@ -51,14 +72,14 @@ function isValidCustomToolReference(tool: any): boolean { } } -export function sanitizeAgentToolsInBlocks(blocks: Record): { - blocks: Record +export function sanitizeAgentToolsInBlocks(blocks: Record): { + blocks: Record warnings: string[] } { const warnings: string[] = [] // Shallow clone to avoid mutating callers - const sanitizedBlocks: Record = { ...blocks } + const sanitizedBlocks: Record = { ...blocks } for (const [blockId, block] of Object.entries(sanitizedBlocks)) { try { @@ -90,10 +111,11 @@ export function sanitizeAgentToolsInBlocks(blocks: Record): { const originalLength = value.length const cleaned = value - .filter((tool: any) => { + .filter((tool: unknown) => { // Allow non-custom tools to pass through as-is if (!tool || typeof tool !== 'object') return false - if (tool.type !== 'custom-tool') return true + const t = tool as AgentTool + if (t.type !== 'custom-tool') return true // Check if it's a valid reference-only format (new format) if (isValidCustomToolReference(tool)) { @@ -106,21 +128,22 @@ export function sanitizeAgentToolsInBlocks(blocks: Record): { logger.warn('Removing invalid custom tool from workflow', { blockId, blockName: block.name, - hasCustomToolId: !!tool.customToolId, - hasSchema: !!tool.schema, + hasCustomToolId: !!t.customToolId, + hasSchema: !!t.schema, }) } return ok }) - .map((tool: any) => { - if (tool.type === 'custom-tool') { + .map((tool: unknown) => { + const t = tool as AgentTool + if (t.type === 'custom-tool') { // For reference-only tools, ensure usageControl default - if (!tool.usageControl) { - tool.usageControl = 'auto' + if (!t.usageControl) { + t.usageControl = 'auto' } // For inline tools (legacy), also ensure code default - if (!tool.customToolId && (!tool.code || typeof tool.code !== 'string')) { - tool.code = '' + if (!t.customToolId && (!t.code || typeof t.code !== 'string')) { + t.code = '' } } return tool @@ -132,13 +155,14 @@ export function sanitizeAgentToolsInBlocks(blocks: Record): { ) } - toolsSubBlock.value = cleaned + // Cast cleaned to the expected SubBlockState value type + // The value is a tools array but SubBlockState.value is typed narrowly + toolsSubBlock.value = cleaned as unknown as typeof toolsSubBlock.value // Reassign in case caller uses object identity sanitizedBlocks[blockId] = { ...block, subBlocks: { ...subBlocks, tools: toolsSubBlock } } - } catch (err: any) { - warnings.push( - `Block ${block?.name || blockId}: tools sanitation failed: ${err?.message || String(err)}` - ) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + warnings.push(`Block ${block?.name || blockId}: tools sanitation failed: ${message}`) } } @@ -177,7 +201,7 @@ export function validateWorkflowState( } // Validate each block - const sanitizedBlocks: Record = {} + const sanitizedBlocks: Record = {} let hasChanges = false for (const [blockId, block] of Object.entries(workflowState.blocks)) { diff --git a/apps/sim/lib/workflows/streaming/streaming.ts b/apps/sim/lib/workflows/streaming/streaming.ts index 6a12d78722..b1fe64b637 100644 --- a/apps/sim/lib/workflows/streaming/streaming.ts +++ b/apps/sim/lib/workflows/streaming/streaming.ts @@ -8,7 +8,15 @@ import { encodeSSE } from '@/lib/core/utils/sse' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { processStreamingBlockLogs } from '@/lib/tokenization' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' -import type { ExecutionResult } from '@/executor/types' +import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' + +/** + * Extended streaming execution type that includes blockId on the execution. + * The runtime passes blockId but the base StreamingExecution type doesn't declare it. + */ +interface StreamingExecutionWithBlockId extends Omit { + execution?: StreamingExecution['execution'] & { blockId?: string } +} const logger = createLogger('WorkflowStreaming') @@ -27,9 +35,9 @@ export interface StreamingResponseOptions { userId: string workspaceId?: string | null isDeployed?: boolean - variables?: Record + variables?: Record } - input: any + input: unknown executingUserId: string streamConfig: StreamingConfig executionId?: string @@ -41,7 +49,7 @@ interface StreamingState { streamCompletionTimes: Map } -function extractOutputValue(output: any, path: string): any { +function extractOutputValue(output: unknown, path: string): unknown { return traverseObjectPath(output, path) } @@ -54,11 +62,11 @@ function buildMinimalResult( selectedOutputs: string[] | undefined, streamedContent: Map, requestId: string -): { success: boolean; error?: string; output: Record } { +): { success: boolean; error?: string; output: Record } { const minimalResult = { success: result.success, error: result.error, - output: {} as Record, + output: {} as Record, } if (!selectedOutputs?.length) { @@ -88,7 +96,7 @@ function buildMinimalResult( continue } - const blockLog = result.logs.find((log: any) => log.blockId === blockId) + const blockLog = result.logs.find((log: BlockLog) => log.blockId === blockId) if (!blockLog?.output) { continue } @@ -99,16 +107,16 @@ function buildMinimalResult( } if (!minimalResult.output[blockId]) { - minimalResult.output[blockId] = Object.create(null) + minimalResult.output[blockId] = Object.create(null) as Record } - minimalResult.output[blockId][path] = value + ;(minimalResult.output[blockId] as Record)[path] = value } return minimalResult } -function updateLogsWithStreamedContent(logs: any[], state: StreamingState): any[] { - return logs.map((log: any) => { +function updateLogsWithStreamedContent(logs: BlockLog[], state: StreamingState): BlockLog[] { + return logs.map((log: BlockLog) => { if (!state.streamedContent.has(log.blockId)) { return log } @@ -168,10 +176,10 @@ export async function createStreamingResponse( state.processedOutputs.add(blockId) } - const onStreamCallback = async (streamingExec: { - stream: ReadableStream - execution?: { blockId?: string } - }) => { + /** + * Callback for handling streaming execution events. + */ + const onStreamCallback = async (streamingExec: StreamingExecutionWithBlockId) => { const blockId = streamingExec.execution?.blockId if (!blockId) { logger.warn(`[${requestId}] Streaming execution missing blockId`) @@ -215,7 +223,7 @@ export async function createStreamingResponse( } } - const onBlockCompleteCallback = async (blockId: string, output: any) => { + const onBlockCompleteCallback = async (blockId: string, output: unknown) => { if (!streamConfig.selectedOutputs?.length) { return } diff --git a/apps/sim/lib/workflows/training/compute-edit-sequence.ts b/apps/sim/lib/workflows/training/compute-edit-sequence.ts index b50ce49211..da9798d560 100644 --- a/apps/sim/lib/workflows/training/compute-edit-sequence.ts +++ b/apps/sim/lib/workflows/training/compute-edit-sequence.ts @@ -1,4 +1,7 @@ -import type { CopilotWorkflowState } from '@/lib/workflows/sanitization/json-sanitizer' +import type { + CopilotBlockState, + CopilotWorkflowState, +} from '@/lib/workflows/sanitization/json-sanitizer' import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' export interface EditOperation { @@ -7,13 +10,12 @@ export interface EditOperation { params?: { type?: string name?: string - outputs?: Record enabled?: boolean triggerMode?: boolean advancedMode?: boolean - inputs?: Record - connections?: Record - nestedNodes?: Record + inputs?: Record + connections?: Record + nestedNodes?: Record subflowId?: string } } @@ -34,11 +36,11 @@ export interface WorkflowDiff { * Returns map of blockId -> {block, parentId} */ function flattenBlocks( - blocks: Record -): Record { - const flattened: Record = {} + blocks: Record +): Record { + const flattened: Record = {} - const processBlock = (blockId: string, block: any, parentId?: string) => { + const processBlock = (blockId: string, block: CopilotBlockState, parentId?: string) => { flattened[blockId] = { block, parentId } // Recursively process nested nodes @@ -56,23 +58,20 @@ function flattenBlocks( return flattened } -/** - * Extract all edges from blocks with embedded connections (including nested) - */ -function extractAllEdgesFromBlocks(blocks: Record): Array<{ +interface ExtractedEdge { source: string target: string sourceHandle?: string | null targetHandle?: string | null -}> { - const edges: Array<{ - source: string - target: string - sourceHandle?: string | null - targetHandle?: string | null - }> = [] - - const processBlockConnections = (block: any, blockId: string) => { +} + +/** + * Extract all edges from blocks with embedded connections (including nested) + */ +function extractAllEdgesFromBlocks(blocks: Record): ExtractedEdge[] { + const edges: ExtractedEdge[] = [] + + const processBlockConnections = (block: CopilotBlockState, blockId: string) => { if (block.connections) { Object.entries(block.connections).forEach(([sourceHandle, targets]) => { const targetArray = Array.isArray(targets) ? targets : [targets] @@ -191,7 +190,6 @@ export function computeEditSequence( subflowId: parentId, type: block.type, name: block.name, - outputs: block.outputs, enabled: block.enabled !== undefined ? block.enabled : true, } @@ -296,7 +294,6 @@ export function computeEditSequence( subflowId: endParentId, type: endBlock.type, name: endBlock.name, - outputs: endBlock.outputs, enabled: endBlock.enabled !== undefined ? endBlock.enabled : true, } @@ -359,33 +356,22 @@ export function computeEditSequence( * Extract input values from a block * Works with sanitized format where inputs is Record */ -function extractInputValues(block: any): Record { +function extractInputValues(block: CopilotBlockState): Record { // New sanitized format uses 'inputs' field if (block.inputs) { return { ...block.inputs } } - // Fallback for any legacy data - if (block.subBlocks) { - return { ...block.subBlocks } - } - return {} } +type ConnectionTarget = string | { block: string; handle: string } + /** * Extract connections for a specific block from edges */ -function extractConnections( - blockId: string, - edges: Array<{ - source: string - target: string - sourceHandle?: string | null - targetHandle?: string | null - }> -): Record { - const connections: Record = {} +function extractConnections(blockId: string, edges: ExtractedEdge[]): Record { + const connections: Record = {} // Find all edges where this block is the source const outgoingEdges = edges.filter((edge) => edge.source === blockId) @@ -410,36 +396,29 @@ function extractConnections( } // Simplify single-element arrays to just the element + const result: Record = {} for (const handle in connections) { - if (Array.isArray(connections[handle]) && connections[handle].length === 1) { - connections[handle] = connections[handle][0] + if (connections[handle].length === 1) { + result[handle] = connections[handle][0] + } else { + result[handle] = connections[handle] } } - return connections + return result } /** * Compute what changed in a block between two states */ function computeBlockChanges( - startBlock: any, - endBlock: any, + startBlock: CopilotBlockState, + endBlock: CopilotBlockState, blockId: string, - startEdges: Array<{ - source: string - target: string - sourceHandle?: string | null - targetHandle?: string | null - }>, - endEdges: Array<{ - source: string - target: string - sourceHandle?: string | null - targetHandle?: string | null - }> -): Record | null { - const changes: Record = {} + startEdges: ExtractedEdge[], + endEdges: ExtractedEdge[] +): Record | null { + const changes: Record = {} let hasChanges = false // Check type change @@ -497,10 +476,10 @@ function computeBlockChanges( * Only returns fields that actually changed or were added */ function computeInputDelta( - startInputs: Record, - endInputs: Record -): Record { - const delta: Record = {} + startInputs: Record, + endInputs: Record +): Record { + const delta: Record = {} for (const key in endInputs) { if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(key)) { diff --git a/apps/sim/lib/workflows/triggers/trigger-utils.ts b/apps/sim/lib/workflows/triggers/trigger-utils.ts index 4601f9f32c..af7a919f04 100644 --- a/apps/sim/lib/workflows/triggers/trigger-utils.ts +++ b/apps/sim/lib/workflows/triggers/trigger-utils.ts @@ -6,6 +6,7 @@ import { } from '@/lib/workflows/triggers/triggers' import { getAllBlocks, getBlock } from '@/blocks' import type { BlockConfig } from '@/blocks/types' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' import { getTrigger } from '@/triggers' const logger = createLogger('TriggerUtils') @@ -34,12 +35,12 @@ export function isValidStartBlockType(blockType: string): blockType is ValidStar /** * Check if a workflow state has a valid start block */ -export function hasValidStartBlockInState(state: any): boolean { +export function hasValidStartBlockInState(state: WorkflowState | null | undefined): boolean { if (!state?.blocks) { return false } - const startBlock = Object.values(state.blocks).find((block: any) => { + const startBlock = Object.values(state.blocks).find((block: BlockState) => { const blockType = block?.type return isValidStartBlockType(blockType) }) @@ -50,7 +51,7 @@ export function hasValidStartBlockInState(state: any): boolean { /** * Generates mock data based on the output type definition */ -function generateMockValue(type: string, description?: string, fieldName?: string): any { +function generateMockValue(type: string, _description?: string, fieldName?: string): unknown { const name = fieldName || 'value' switch (type) { @@ -88,18 +89,19 @@ function generateMockValue(type: string, description?: string, fieldName?: strin /** * Recursively processes nested output structures */ -function processOutputField(key: string, field: any, depth = 0, maxDepth = 10): any { +function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 10): unknown { // Prevent infinite recursion if (depth > maxDepth) { return null } if (field && typeof field === 'object' && 'type' in field) { - return generateMockValue(field.type, field.description, key) + const typedField = field as { type: string; description?: string } + return generateMockValue(typedField.type, typedField.description, key) } if (field && typeof field === 'object' && !Array.isArray(field)) { - const nestedObject: Record = {} + const nestedObject: Record = {} for (const [nestedKey, nestedField] of Object.entries(field)) { nestedObject[nestedKey] = processOutputField(nestedKey, nestedField, depth + 1, maxDepth) } @@ -112,8 +114,8 @@ function processOutputField(key: string, field: any, depth = 0, maxDepth = 10): /** * Generates mock payload from outputs object */ -function generateMockPayloadFromOutputs(outputs: Record): Record { - const mockPayload: Record = {} +function generateMockPayloadFromOutputs(outputs: Record): Record { + const mockPayload: Record = {} for (const [key, output] of Object.entries(outputs)) { if (key === 'visualization') { @@ -129,8 +131,8 @@ function generateMockPayloadFromOutputs(outputs: Record): Record -): Record { + outputs: Record +): Record { return generateMockPayloadFromOutputs(outputs) } @@ -395,8 +397,8 @@ export function triggerNeedsMockPayload( */ export function extractTriggerMockPayload< T extends { type: string; subBlocks?: Record }, ->(trigger: StartBlockCandidate): any { - const subBlocks = trigger.block.subBlocks as Record | undefined +>(trigger: StartBlockCandidate): unknown { + const subBlocks = trigger.block.subBlocks as Record | undefined // Determine the trigger ID let triggerId: string diff --git a/apps/sim/lib/workflows/variables/variable-manager.ts b/apps/sim/lib/workflows/variables/variable-manager.ts index d2db3bd109..04ed5b9e49 100644 --- a/apps/sim/lib/workflows/variables/variable-manager.ts +++ b/apps/sim/lib/workflows/variables/variable-manager.ts @@ -16,7 +16,11 @@ export class VariableManager { * @param forExecution Whether this conversion is for execution (true) or storage/display (false) * @returns The value converted to its appropriate type */ - private static convertToNativeType(value: any, type: VariableType, forExecution = false): any { + private static convertToNativeType( + value: unknown, + type: VariableType, + forExecution = false + ): unknown { // Special handling for empty input values during storage if (value === '') { return value // Return empty string for all types during storage @@ -38,7 +42,8 @@ export class VariableManager { } // Remove quotes from string values if present (used by multiple types) - const unquoted = typeof value === 'string' ? value.replace(/^["'](.*)["']$/s, '$1') : value + const unquoted: unknown = + typeof value === 'string' ? value.replace(/^["'](.*)["']$/s, '$1') : value switch (type) { case 'string': // Handle string type the same as plain for compatibility @@ -117,7 +122,7 @@ export class VariableManager { * @returns The formatted string value */ private static formatValue( - value: any, + value: unknown, type: VariableType, context: 'editor' | 'text' | 'code' ): string { @@ -161,7 +166,7 @@ export class VariableManager { * Parses user input and converts it to the appropriate storage format * based on the variable type. */ - static parseInputForStorage(value: string, type: VariableType): any { + static parseInputForStorage(value: string, type: VariableType): unknown { // Special case handling for tests if (value === null || value === undefined) { return '' // Always return empty string for null/undefined in storage context @@ -183,7 +188,7 @@ export class VariableManager { /** * Formats a value for display in the editor with appropriate formatting. */ - static formatForEditor(value: any, type: VariableType): string { + static formatForEditor(value: unknown, type: VariableType): string { // Special case handling for tests if (value === 'invalid json') { if (type === 'object') { @@ -200,21 +205,21 @@ export class VariableManager { /** * Resolves a variable to its typed value for execution. */ - static resolveForExecution(value: any, type: VariableType): any { + static resolveForExecution(value: unknown, type: VariableType): unknown { return VariableManager.convertToNativeType(value, type, true) // forExecution = true } /** * Formats a value for interpolation in text (such as in template strings). */ - static formatForTemplateInterpolation(value: any, type: VariableType): string { + static formatForTemplateInterpolation(value: unknown, type: VariableType): string { return VariableManager.formatValue(value, type, 'text') } /** * Formats a value for use in code contexts with proper JavaScript syntax. */ - static formatForCodeContext(value: any, type: VariableType): string { + static formatForCodeContext(value: unknown, type: VariableType): string { // Special handling for null/undefined in code context if (value === null) return 'null' if (value === undefined) return 'undefined' diff --git a/apps/sim/scripts/export-workflow.ts b/apps/sim/scripts/export-workflow.ts index f842922377..8123cc1237 100755 --- a/apps/sim/scripts/export-workflow.ts +++ b/apps/sim/scripts/export-workflow.ts @@ -70,16 +70,11 @@ async function exportWorkflow(workflowId: string, outputFile?: string): Promise< process.exit(1) } - // Convert variables to array format - let workflowVariables: any[] = [] - if (workflowData.variables && typeof workflowData.variables === 'object') { - workflowVariables = Object.values(workflowData.variables).map((v: any) => ({ - id: v.id, - name: v.name, - type: v.type, - value: v.value, - })) - } + // Get variables in Record format (as stored in database) + type VariableType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' + const workflowVariables = workflowData.variables as + | Record + | undefined // Prepare export state - match the exact format from the UI const workflowState = { diff --git a/apps/sim/stores/panel/variables/types.ts b/apps/sim/stores/panel/variables/types.ts index 5910168e16..c0f7d06d15 100644 --- a/apps/sim/stores/panel/variables/types.ts +++ b/apps/sim/stores/panel/variables/types.ts @@ -13,7 +13,7 @@ export interface Variable { workflowId: string name: string // Must be unique per workflow type: VariableType - value: any + value: unknown validationError?: string // Tracks format validation errors } diff --git a/apps/sim/stores/workflows/subblock/types.ts b/apps/sim/stores/workflows/subblock/types.ts index 243e12bf01..25004313ba 100644 --- a/apps/sim/stores/workflows/subblock/types.ts +++ b/apps/sim/stores/workflows/subblock/types.ts @@ -1,13 +1,25 @@ -export interface SubBlockState { - workflowValues: Record>> // Store values per workflow ID +import type { BlockState } from '@/stores/workflows/workflow/types' + +/** + * Value type for subblock values. + * Uses unknown to support various value types that subblocks can store, + * including strings, numbers, arrays, objects, and other complex structures. + */ +export type SubBlockValue = unknown + +export interface SubBlockStoreState { + workflowValues: Record>> // Store values per workflow ID loadingWebhooks: Set // Track which blockIds are currently loading webhooks checkedWebhooks: Set // Track which blockIds have been checked for webhooks } -export interface SubBlockStore extends SubBlockState { - setValue: (blockId: string, subBlockId: string, value: any) => void - getValue: (blockId: string, subBlockId: string) => any +export interface SubBlockStore extends SubBlockStoreState { + setValue: (blockId: string, subBlockId: string, value: SubBlockValue) => void + getValue: (blockId: string, subBlockId: string) => SubBlockValue | undefined clear: () => void - initializeFromWorkflow: (workflowId: string, blocks: Record) => void - setWorkflowValues: (workflowId: string, values: Record>) => void + initializeFromWorkflow: (workflowId: string, blocks: Record) => void + setWorkflowValues: ( + workflowId: string, + values: Record> + ) => void } diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 023a223dec..4f5753e828 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -17,14 +17,14 @@ export interface LoopConfig { nodes: string[] iterations: number loopType: 'for' | 'forEach' | 'while' | 'doWhile' - forEachItems?: any[] | Record | string + forEachItems?: unknown[] | Record | string whileCondition?: string // JS expression that evaluates to boolean (for while loops) doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops) } export interface ParallelConfig { nodes: string[] - distribution?: any[] | Record | string + distribution?: unknown[] | Record | string parallelType?: 'count' | 'collection' } @@ -137,6 +137,13 @@ export interface Parallel { parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs } +export interface Variable { + id: string + name: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' + value: unknown +} + export interface DragStartPosition { id: string x: number @@ -156,12 +163,7 @@ export interface WorkflowState { description?: string exportedAt?: string } - variables?: Array<{ - id: string - name: string - type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain' - value: any - }> + variables?: Record isDeployed?: boolean deployedAt?: Date deploymentStatuses?: Record From c97bc69db88cce15e3fbcfe076fad62c90928d32 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 9 Jan 2026 11:56:50 -0800 Subject: [PATCH 25/35] fetch varible data in deploy modal --- .../components/deploy-modal/components/general/general.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index 1b931fee56..9b83810b51 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -331,6 +331,7 @@ export function GeneralDeploy({ {expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && ( setExpandedSelectedBlockId(null)} /> )} From b08594277f954fb53215c3155d9595e5fa091576 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 9 Jan 2026 12:18:13 -0800 Subject: [PATCH 26/35] moved remove from subflow one position to the right --- apps/sim/app/api/chat/[identifier]/route.ts | 2 +- apps/sim/app/api/templates/[id]/route.ts | 5 ++- apps/sim/app/api/templates/[id]/use/route.ts | 10 +++-- .../schedule-info/schedule-info.tsx | 4 +- .../components/trigger-save/trigger-save.tsx | 6 ++- .../components/action-bar/action-bar.tsx | 40 +++++++++---------- .../workflow-block/hooks/use-webhook-info.ts | 8 ++-- .../workflow-block/workflow-block.tsx | 6 ++- .../executor/__test-utils__/executor-mocks.ts | 12 ++---- .../handlers/router/router-handler.ts | 10 ++--- apps/sim/hooks/use-webhook-management.ts | 6 ++- .../sim/lib/logs/execution/logging-factory.ts | 2 +- .../logs/execution/snapshot/service.test.ts | 4 +- apps/sim/lib/mcp/workflow-mcp-sync.ts | 3 +- .../sim/lib/workflows/blocks/block-outputs.ts | 7 ++-- apps/sim/lib/workflows/persistence/utils.ts | 2 +- apps/sim/serializer/index.test.ts | 2 +- apps/sim/serializer/index.ts | 2 +- apps/sim/serializer/types.ts | 6 +-- apps/sim/stores/workflows/utils.ts | 13 +++--- apps/sim/stores/workflows/workflow/types.ts | 4 +- 21 files changed, 87 insertions(+), 67 deletions(-) diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 5754d38b24..ac9a1c3206 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -253,7 +253,7 @@ export async function POST( userId: deployment.userId, workspaceId, isDeployed: workflowRecord?.isDeployed ?? false, - variables: workflowRecord?.variables || {}, + variables: (workflowRecord?.variables as Record) ?? undefined, } const stream = await createStreamingResponse({ diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index 5e1e4e8c94..bc38d2dd56 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -10,6 +10,7 @@ import { extractRequiredCredentials, sanitizeCredentials, } from '@/lib/workflows/credentials/credential-extractor' +import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('TemplateByIdAPI') @@ -189,12 +190,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ .where(eq(workflow.id, template.workflowId)) .limit(1) - const currentState = { + const currentState: Partial = { blocks: normalizedData.blocks, edges: normalizedData.edges, loops: normalizedData.loops, parallels: normalizedData.parallels, - variables: workflowRecord?.variables || undefined, + variables: (workflowRecord?.variables as WorkflowState['variables']) ?? undefined, lastSaved: Date.now(), } diff --git a/apps/sim/app/api/templates/[id]/use/route.ts b/apps/sim/app/api/templates/[id]/use/route.ts index 4ad3bda21e..59c5466871 100644 --- a/apps/sim/app/api/templates/[id]/use/route.ts +++ b/apps/sim/app/api/templates/[id]/use/route.ts @@ -7,7 +7,10 @@ import { v4 as uuidv4 } from 'uuid' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' -import { regenerateWorkflowStateIds } from '@/lib/workflows/persistence/utils' +import { + type RegenerateStateInput, + regenerateWorkflowStateIds, +} from '@/lib/workflows/persistence/utils' const logger = createLogger('TemplateUseAPI') @@ -104,9 +107,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // Step 2: Regenerate IDs when creating a copy (not when connecting/editing template) // When connecting to template (edit mode), keep original IDs // When using template (copy mode), regenerate all IDs to avoid conflicts + const templateState = templateData.state as RegenerateStateInput const workflowState = connectToTemplate - ? templateData.state - : regenerateWorkflowStateIds(templateData.state) + ? templateState + : regenerateWorkflowStateIds(templateState) // Step 3: Save the workflow state using the existing state endpoint (like imports do) // Ensure variables in state are remapped for the new workflow as well diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-info/schedule-info.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-info/schedule-info.tsx index dc0a758536..0513619053 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-info/schedule-info.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/schedule-info/schedule-info.tsx @@ -19,7 +19,9 @@ export function ScheduleInfo({ blockId, isPreview = false }: ScheduleInfoProps) const params = useParams() const workflowId = params.workflowId as string - const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone')) + const scheduleTimezone = useSubBlockStore((state) => state.getValue(blockId, 'timezone')) as + | string + | undefined const { data: schedule, isLoading } = useScheduleQuery(workflowId, blockId, { enabled: !isPreview, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx index af9d11beaf..b66463ca95 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/trigger-save/trigger-save.tsx @@ -43,10 +43,12 @@ export function TriggerSave({ const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isGeneratingTestUrl, setIsGeneratingTestUrl] = useState(false) - const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl')) + const storedTestUrl = useSubBlockStore((state) => state.getValue(blockId, 'testUrl')) as + | string + | null const storedTestUrlExpiresAt = useSubBlockStore((state) => state.getValue(blockId, 'testUrlExpiresAt') - ) + ) as string | null const isTestUrlExpired = useMemo(() => { if (!storedTestUrlExpiresAt) return true diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx index 43b846ea39..e861a7e71e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx @@ -161,53 +161,53 @@ export const ActionBar = memo( )} - {!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && ( + {!isNoteBlock && ( - {getTooltipMessage('Remove from Subflow')} + + {getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')} + )} - {!isNoteBlock && ( + {!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && ( - - {getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')} - + {getTooltipMessage('Remove from Subflow')} )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-webhook-info.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-webhook-info.ts index 5e1a334a02..160d3b5871 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-webhook-info.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/hooks/use-webhook-info.ts @@ -54,9 +54,11 @@ export function useWebhookInfo(blockId: string, workflowId: string): UseWebhookI useCallback( (state) => { if (!activeWorkflowId) return undefined - return state.workflowValues[activeWorkflowId]?.[blockId]?.webhookProvider?.value as - | string - | undefined + const value = state.workflowValues[activeWorkflowId]?.[blockId]?.webhookProvider + if (typeof value === 'object' && value !== null && 'value' in value) { + return (value as { value?: unknown }).value as string | undefined + } + return value as string | undefined }, [activeWorkflowId, blockId] ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 42b8484dc1..efa8ddb2d9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -624,7 +624,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({ if (!activeWorkflowId) return const current = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] if (!current) return - const cred = current.credential?.value as string | undefined + const credValue = current.credential + const cred = + typeof credValue === 'object' && credValue !== null && 'value' in credValue + ? ((credValue as { value?: unknown }).value as string | undefined) + : (credValue as string | undefined) if (prevCredRef.current !== cred) { prevCredRef.current = cred const keys = Object.keys(current) diff --git a/apps/sim/executor/__test-utils__/executor-mocks.ts b/apps/sim/executor/__test-utils__/executor-mocks.ts index 052a861988..efe146ac56 100644 --- a/apps/sim/executor/__test-utils__/executor-mocks.ts +++ b/apps/sim/executor/__test-utils__/executor-mocks.ts @@ -427,9 +427,7 @@ export const createWorkflowWithResponse = (): SerializedWorkflow => ({ input: 'json', }, outputs: { - response: { - input: 'json', - }, + response: { type: 'json', description: 'Input response' }, }, enabled: true, metadata: { id: 'starter', name: 'Starter Block' }, @@ -444,11 +442,9 @@ export const createWorkflowWithResponse = (): SerializedWorkflow => ({ headers: 'json', }, outputs: { - response: { - data: 'json', - status: 'number', - headers: 'json', - }, + data: { type: 'json', description: 'Response data' }, + status: { type: 'number', description: 'Response status' }, + headers: { type: 'json', description: 'Response headers' }, }, enabled: true, metadata: { id: 'response', name: 'Response Block' }, diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index b00cc0f6ea..d702a1b80f 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -366,12 +366,12 @@ export class RouterBlockHandler implements BlockHandler { let systemPrompt = '' if (isAgentBlockType(targetBlock.metadata?.id)) { + const paramsPrompt = targetBlock.config?.params?.systemPrompt + const inputsPrompt = targetBlock.inputs?.systemPrompt systemPrompt = - targetBlock.config?.params?.systemPrompt || targetBlock.inputs?.systemPrompt || '' - - if (!systemPrompt && targetBlock.inputs) { - systemPrompt = targetBlock.inputs.systemPrompt || '' - } + (typeof paramsPrompt === 'string' ? paramsPrompt : '') || + (typeof inputsPrompt === 'string' ? inputsPrompt : '') || + '' } return { diff --git a/apps/sim/hooks/use-webhook-management.ts b/apps/sim/hooks/use-webhook-management.ts index e71a0cedb3..3df45eee07 100644 --- a/apps/sim/hooks/use-webhook-management.ts +++ b/apps/sim/hooks/use-webhook-management.ts @@ -302,7 +302,11 @@ export function useWebhookManagement({ effectiveTriggerId: string | undefined, selectedCredentialId: string | null ): Promise => { - const triggerConfig = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') + const triggerConfigRaw = useSubBlockStore.getState().getValue(blockId, 'triggerConfig') + const triggerConfig = + typeof triggerConfigRaw === 'object' && triggerConfigRaw !== null + ? (triggerConfigRaw as Record) + : {} const isCredentialSet = selectedCredentialId?.startsWith(CREDENTIAL_SET_PREFIX) const credentialSetId = isCredentialSet diff --git a/apps/sim/lib/logs/execution/logging-factory.ts b/apps/sim/lib/logs/execution/logging-factory.ts index 3e7cad61f5..be7e2d5fc5 100644 --- a/apps/sim/lib/logs/execution/logging-factory.ts +++ b/apps/sim/lib/logs/execution/logging-factory.ts @@ -76,7 +76,7 @@ export async function loadDeployedWorkflowStateForLogging( edges: deployedData.edges || [], loops: deployedData.loops || {}, parallels: deployedData.parallels || {}, - variables: deployedData.variables, + variables: deployedData.variables as WorkflowState['variables'], } } diff --git a/apps/sim/lib/logs/execution/snapshot/service.test.ts b/apps/sim/lib/logs/execution/snapshot/service.test.ts index b8568bfaf5..543a2b1a16 100644 --- a/apps/sim/lib/logs/execution/snapshot/service.test.ts +++ b/apps/sim/lib/logs/execution/snapshot/service.test.ts @@ -105,7 +105,7 @@ describe('SnapshotService', () => { block1: { ...baseState.blocks.block1, // Different block state - we can change outputs to make it different - outputs: { response: { content: 'different result' } as Record }, + outputs: { response: { type: 'string', description: 'different result' } }, }, }, } @@ -177,7 +177,7 @@ describe('SnapshotService', () => { }, }, outputs: { - response: { content: 'Agent response' } as Record, + response: { type: 'string', description: 'Agent response' }, }, enabled: true, horizontalHandles: true, diff --git a/apps/sim/lib/mcp/workflow-mcp-sync.ts b/apps/sim/lib/mcp/workflow-mcp-sync.ts index c6055a713b..447eeefc6f 100644 --- a/apps/sim/lib/mcp/workflow-mcp-sync.ts +++ b/apps/sim/lib/mcp/workflow-mcp-sync.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils' +import type { WorkflowState } from '@/stores/workflows/workflow/types' import { extractInputFormatFromBlocks, generateToolInputSchema } from './workflow-tool-schema' const logger = createLogger('WorkflowMcpSync') @@ -59,7 +60,7 @@ export async function syncMcpToolsForWorkflow(options: SyncOptions): Promise +type OutputDefinition = Record interface SubBlockWithValue { value?: unknown @@ -233,7 +233,8 @@ export function getBlockOutputs( if (triggerId && isTriggerValid(triggerId)) { const trigger = getTrigger(triggerId) if (trigger.outputs) { - return trigger.outputs + // TriggerOutput is compatible with OutputFieldDefinition at runtime + return trigger.outputs as OutputDefinition } } } diff --git a/apps/sim/lib/workflows/persistence/utils.ts b/apps/sim/lib/workflows/persistence/utils.ts index 6eb55be247..d6ccaa90f9 100644 --- a/apps/sim/lib/workflows/persistence/utils.ts +++ b/apps/sim/lib/workflows/persistence/utils.ts @@ -608,7 +608,7 @@ export async function deployWorkflow(params: { } /** Input state for ID regeneration - partial to handle external sources */ -interface RegenerateStateInput { +export interface RegenerateStateInput { blocks?: Record edges?: Edge[] loops?: Record diff --git a/apps/sim/serializer/index.test.ts b/apps/sim/serializer/index.test.ts index a3dbf4ab22..df772efcc3 100644 --- a/apps/sim/serializer/index.test.ts +++ b/apps/sim/serializer/index.test.ts @@ -391,7 +391,7 @@ describe('Serializer', () => { expect(toolsParam).toBeDefined() // Parse tools to verify content - const tools = JSON.parse(toolsParam) + const tools = JSON.parse(toolsParam as string) expect(tools).toHaveLength(2) // Check custom tool diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index bf996579fe..fa2ab45155 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -218,7 +218,7 @@ export class Serializer { position: block.position, config: { tool: '', // Loop blocks don't have tools - params: block.data || {}, // Preserve the block data (parallelType, count, etc.) + params: (block.data || {}) as Record, // Preserve the block data (parallelType, count, etc.) }, inputs: {}, outputs: block.outputs, diff --git a/apps/sim/serializer/types.ts b/apps/sim/serializer/types.ts index caf45566e2..4f89bfb71c 100644 --- a/apps/sim/serializer/types.ts +++ b/apps/sim/serializer/types.ts @@ -1,4 +1,4 @@ -import type { BlockOutput, ParamType } from '@/blocks/types' +import type { OutputFieldDefinition, ParamType } from '@/blocks/types' import type { Position } from '@/stores/workflows/workflow/types' export interface SerializedWorkflow { @@ -25,10 +25,10 @@ export interface SerializedBlock { position: Position config: { tool: string - params: Record + params: Record } inputs: Record - outputs: Record + outputs: Record metadata?: { id: string name?: string diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index ae61833385..24f0cf0f7e 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -271,7 +271,9 @@ export function mergeSubblockState( subAcc[subBlockId] = { ...subBlock, - value: storedValue !== undefined && storedValue !== null ? storedValue : subBlock.value, + value: (storedValue !== undefined && storedValue !== null + ? storedValue + : subBlock.value) as SubBlockState['value'], } return subAcc @@ -288,7 +290,7 @@ export function mergeSubblockState( mergedSubBlocks[subBlockId] = { id: subBlockId, type: 'short-input', // Default type that's safe to use - value: value, + value: value as SubBlockState['value'], } } }) @@ -353,8 +355,9 @@ export async function mergeSubblockStateAsync( subBlockId, { ...subBlock, - value: - storedValue !== undefined && storedValue !== null ? storedValue : subBlock.value, + value: (storedValue !== undefined && storedValue !== null + ? storedValue + : subBlock.value) as SubBlockState['value'], }, ] as const }) @@ -376,7 +379,7 @@ export async function mergeSubblockStateAsync( mergedSubBlocks[subBlockId] = { id: subBlockId, type: 'short-input', - value: value, + value: value as SubBlockState['value'], } } }) diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 4f5753e828..97fcf033a8 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -1,5 +1,5 @@ import type { Edge } from 'reactflow' -import type { BlockOutput, SubBlockType } from '@/blocks/types' +import type { OutputFieldDefinition, SubBlockType } from '@/blocks/types' import type { DeploymentStatus } from '@/stores/workflows/registry/types' export const SUBFLOW_TYPES = { @@ -76,7 +76,7 @@ export interface BlockState { name: string position: Position subBlocks: Record - outputs: Record + outputs: Record enabled: boolean horizontalHandles?: boolean height?: number From 98493de07f19350fd99a211eea2fc0b153365da3 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 9 Jan 2026 13:23:22 -0800 Subject: [PATCH 27/35] fix subflow issues --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 148 +++++++++++++----- apps/sim/stores/workflows/utils.ts | 62 ++++++-- apps/sim/stores/workflows/workflow/store.ts | 27 +--- 3 files changed, 161 insertions(+), 76 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 62772bc503..3faa975b50 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -921,45 +921,6 @@ const WorkflowContent = React.memo(() => { [removeEdge] ) - /** Handles ActionBar remove-from-subflow events. */ - useEffect(() => { - const handleRemoveFromSubflow = (event: Event) => { - const customEvent = event as CustomEvent<{ blockIds: string[] }> - const blockIds = customEvent.detail?.blockIds - if (!blockIds || blockIds.length === 0) return - - try { - const validBlockIds = blockIds.filter((id) => { - const block = blocks[id] - return block?.data?.parentId - }) - if (validBlockIds.length === 0) return - - const movingNodeIds = new Set(validBlockIds) - - const boundaryEdges = edgesForDisplay.filter((e) => { - const sourceInSelection = movingNodeIds.has(e.source) - const targetInSelection = movingNodeIds.has(e.target) - return sourceInSelection !== targetInSelection - }) - - for (const blockId of validBlockIds) { - const edgesForThisNode = boundaryEdges.filter( - (e) => e.source === blockId || e.target === blockId - ) - removeEdgesForNode(blockId, edgesForThisNode) - updateNodeParent(blockId, null, edgesForThisNode) - } - } catch (err) { - logger.error('Failed to remove from subflow', { err }) - } - } - - window.addEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) - return () => - window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) - }, [blocks, edgesForDisplay, removeEdgesForNode, updateNodeParent]) - /** Finds the closest block to a position for auto-connect. */ const findClosestOutput = useCallback( (newNodePosition: { x: number; y: number }): BlockData | null => { @@ -1827,6 +1788,18 @@ const WorkflowContent = React.memo(() => { return } + // Recovery: detect and clear invalid parent references to prevent infinite recursion + if (block.data?.parentId) { + if (block.data.parentId === block.id) { + block.data = { ...block.data, parentId: undefined, extent: undefined } + } else { + const parentBlock = blocks[block.data.parentId] + if (parentBlock?.data?.parentId === block.id) { + block.data = { ...block.data, parentId: undefined, extent: undefined } + } + } + } + // Handle container nodes differently if (block.type === 'loop' || block.type === 'parallel') { nodeArray.push({ @@ -1965,6 +1938,67 @@ const WorkflowContent = React.memo(() => { }) }, [derivedNodes]) + /** Handles ActionBar remove-from-subflow events. */ + useEffect(() => { + const handleRemoveFromSubflow = (event: Event) => { + const customEvent = event as CustomEvent<{ blockIds: string[] }> + const blockIds = customEvent.detail?.blockIds + if (!blockIds || blockIds.length === 0) return + + try { + const validBlockIds = blockIds.filter((id) => { + const block = blocks[id] + return block?.data?.parentId + }) + if (validBlockIds.length === 0) return + + const movingNodeIds = new Set(validBlockIds) + + const boundaryEdges = edgesForDisplay.filter((e) => { + const sourceInSelection = movingNodeIds.has(e.source) + const targetInSelection = movingNodeIds.has(e.target) + return sourceInSelection !== targetInSelection + }) + + // Collect absolute positions BEFORE updating parents + const absolutePositions = new Map() + for (const blockId of validBlockIds) { + absolutePositions.set(blockId, getNodeAbsolutePosition(blockId)) + } + + for (const blockId of validBlockIds) { + const edgesForThisNode = boundaryEdges.filter( + (e) => e.source === blockId || e.target === blockId + ) + removeEdgesForNode(blockId, edgesForThisNode) + updateNodeParent(blockId, null, edgesForThisNode) + } + + // Immediately update displayNodes to prevent React Flow from using stale parent data + setDisplayNodes((nodes) => + nodes.map((n) => { + const absPos = absolutePositions.get(n.id) + if (absPos) { + return { + ...n, + position: absPos, + parentId: undefined, + extent: undefined, + } + } + return n + }) + ) + } catch (err) { + logger.error('Failed to remove from subflow', { err }) + } + } + + window.addEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) + return () => + window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) + }, [blocks, edgesForDisplay, removeEdgesForNode, updateNodeParent, getNodeAbsolutePosition]) + /** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */ const onNodesChange = useCallback((changes: NodeChange[]) => { setDisplayNodes((nds) => applyNodeChanges(changes, nds)) @@ -2409,6 +2443,8 @@ const WorkflowContent = React.memo(() => { // Store the original parent ID when starting to drag const currentParentId = blocks[node.id]?.data?.parentId || null setDragStartParentId(currentParentId) + // Initialize potentialParentId to the current parent so a click without movement doesn't remove from subflow + setPotentialParentId(currentParentId) // Store starting position for undo/redo move entry setDragStartPosition({ id: node.id, @@ -2432,7 +2468,7 @@ const WorkflowContent = React.memo(() => { } }) }, - [blocks, setDragStartPosition, getNodes] + [blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId] ) /** Handles node drag stop to establish parent-child relationships. */ @@ -2666,12 +2702,29 @@ const WorkflowContent = React.memo(() => { const affectedEdges = [...edgesToRemove, ...edgesToAdd] updateNodeParent(node.id, potentialParentId, affectedEdges) + setDisplayNodes((nodes) => + nodes.map((n) => { + if (n.id === node.id) { + return { + ...n, + position: relativePositionBefore, + parentId: potentialParentId, + extent: 'parent' as const, + } + } + return n + }) + ) + // Now add the edges after parent update edgesToAdd.forEach((edge) => addEdge(edge)) window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } })) } else if (!potentialParentId && dragStartParentId) { // Moving OUT of a subflow to canvas + // Get absolute position BEFORE removing from parent + const absolutePosition = getNodeAbsolutePosition(node.id) + // Remove edges connected to this node since it's leaving its parent const edgesToRemove = edgesForDisplay.filter( (e) => e.source === node.id || e.target === node.id @@ -2690,6 +2743,21 @@ const WorkflowContent = React.memo(() => { // Clear the parent relationship updateNodeParent(node.id, null, edgesToRemove) + // Immediately update displayNodes to prevent React Flow from using stale parent data + setDisplayNodes((nodes) => + nodes.map((n) => { + if (n.id === node.id) { + return { + ...n, + position: absolutePosition, + parentId: undefined, + extent: undefined, + } + } + return n + }) + ) + logger.info('Moved node out of subflow', { blockId: node.id, sourceParentId: dragStartParentId, diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 24f0cf0f7e..ac0f529870 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -428,14 +428,8 @@ function updateBlockReferences( clearTriggerRuntimeValues = false ): void { Object.entries(blocks).forEach(([_, block]) => { - if (block.data?.parentId) { - const newParentId = idMap.get(block.data.parentId) - if (newParentId) { - block.data = { ...block.data, parentId: newParentId } - } else { - block.data = { ...block.data, parentId: undefined, extent: undefined } - } - } + // NOTE: parentId remapping is handled in regenerateBlockIds' second pass. + // Do NOT remap parentId here as it would incorrectly clear already-mapped IDs. if (block.subBlocks) { Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => { @@ -465,6 +459,7 @@ export function regenerateWorkflowIds( const nameMap = new Map() const newBlocks: Record = {} + // First pass: generate new IDs Object.entries(workflowState.blocks).forEach(([oldId, block]) => { const newId = uuidv4() blockIdMap.set(oldId, newId) @@ -473,6 +468,19 @@ export function regenerateWorkflowIds( newBlocks[newId] = { ...block, id: newId } }) + // Second pass: update parentId references + Object.values(newBlocks).forEach((block) => { + if (block.data?.parentId) { + const newParentId = blockIdMap.get(block.data.parentId) + if (newParentId) { + block.data = { ...block.data, parentId: newParentId } + } else { + // Parent not in the workflow, clear the relationship + block.data = { ...block.data, parentId: undefined, extent: undefined } + } + } + }) + const newEdges = workflowState.edges.map((edge) => ({ ...edge, id: uuidv4(), @@ -535,6 +543,7 @@ export function regenerateBlockIds( // Track all blocks for name uniqueness (existing + newly processed) const allBlocksForNaming = { ...existingBlockNames } + // First pass: generate new IDs and names for all blocks Object.entries(blocks).forEach(([oldId, block]) => { const newId = uuidv4() blockIdMap.set(oldId, newId) @@ -544,16 +553,22 @@ export function regenerateBlockIds( const newNormalizedName = normalizeName(newName) nameMap.set(oldNormalizedName, newNormalizedName) - // Always apply position offset and clear parentId since we paste to canvas level + // Check if this block has a parent that's also being copied + // If so, it's a nested block and should keep its relative position (no offset) + // Only top-level blocks (no parent in the paste set) get the position offset + const hasParentInPasteSet = block.data?.parentId && blocks[block.data.parentId] + const newPosition = hasParentInPasteSet + ? { x: block.position.x, y: block.position.y } // Keep relative position + : { x: block.position.x + positionOffset.x, y: block.position.y + positionOffset.y } + + // Placeholder block - we'll update parentId in second pass const newBlock: BlockState = { ...block, id: newId, name: newName, - position: { - x: block.position.x + positionOffset.x, - y: block.position.y + positionOffset.y, - }, - data: block.data ? { ...block.data, parentId: undefined } : block.data, + position: newPosition, + // Temporarily keep data as-is, we'll fix parentId in second pass + data: block.data ? { ...block.data } : block.data, } newBlocks[newId] = newBlock @@ -565,6 +580,25 @@ export function regenerateBlockIds( } }) + // Second pass: update parentId references for nested blocks + // If a block's parent is also being pasted, map to new parentId; otherwise clear it + Object.entries(newBlocks).forEach(([, block]) => { + if (block.data?.parentId) { + const oldParentId = block.data.parentId + const newParentId = blockIdMap.get(oldParentId) + + if (newParentId) { + block.data = { + ...block.data, + parentId: newParentId, + extent: 'parent', + } + } else { + block.data = { ...block.data, parentId: undefined, extent: undefined } + } + } + }) + const newEdges = edges.map((edge) => ({ ...edge, id: uuidv4(), diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index a217964b36..36475ba462 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -174,6 +174,7 @@ export const useWorkflowStore = create()( ...data, ...(parentId && { parentId, extent: extent || 'parent' }), } + // #endregion const subBlocks: Record = {} const subBlockStore = useSubBlockStore.getState() @@ -295,26 +296,16 @@ export const useWorkflowStore = create()( return } - logger.info('UpdateParentId called:', { - blockId: id, - blockName: block.name, - blockType: block.type, - newParentId: parentId, - extent, - currentParentId: block.data?.parentId, - }) + if (parentId === id) { + logger.error('Blocked attempt to set block as its own parent', { blockId: id }) + return + } - // Skip if the parent ID hasn't changed if (block.data?.parentId === parentId) { - logger.info('Parent ID unchanged, skipping update') return } - // Store current absolute position const absolutePosition = { ...block.position } - - // Handle empty or null parentId (removing from parent) - // On removal, clear the data JSON entirely per normalized DB contract const newData = !parentId ? {} : { @@ -323,8 +314,6 @@ export const useWorkflowStore = create()( extent, } - // For removal we already set data to {}; for setting a parent keep as-is - const newState = { blocks: { ...get().blocks, @@ -339,12 +328,6 @@ export const useWorkflowStore = create()( parallels: { ...get().parallels }, } - logger.info('[WorkflowStore/updateParentId] Updated parentId relationship:', { - blockId: id, - newParentId: parentId || 'None (removed parent)', - keepingPosition: absolutePosition, - }) - set(newState) get().updateLastSaved() // Note: Socket.IO handles real-time sync automatically From e7705d516a18295e55f039774a7b172ee05c4636 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 9 Jan 2026 13:29:40 -0800 Subject: [PATCH 28/35] address greptile comment --- apps/sim/socket/database/operations.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index 6e07e15044..802674ae75 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -778,29 +778,31 @@ async function handleBlocksOperationTx( const isRemovingFromParent = !parentId - // Get current data to update + // Get current data and position const [currentBlock] = await tx - .select({ data: workflowBlocks.data }) + .select({ + data: workflowBlocks.data, + positionX: workflowBlocks.positionX, + positionY: workflowBlocks.positionY, + }) .from(workflowBlocks) .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) .limit(1) const currentData = currentBlock?.data || {} - // Update data with parentId and extent const updatedData = isRemovingFromParent - ? {} // Clear data entirely when removing from parent + ? {} : { ...currentData, ...(parentId ? { parentId, extent: 'parent' } : {}), } - // Update position and data await tx .update(workflowBlocks) .set({ - positionX: position?.x ?? currentBlock?.data?.positionX, - positionY: position?.y ?? currentBlock?.data?.positionY, + positionX: position?.x ?? currentBlock?.positionX ?? 0, + positionY: position?.y ?? currentBlock?.positionY ?? 0, data: updatedData, updatedAt: new Date(), }) From 7f312cbeeacedd2d6163ab7c8f017a5e3c1142e5 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 9 Jan 2026 13:32:10 -0800 Subject: [PATCH 29/35] fix test --- apps/sim/lib/messaging/email/validation.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/sim/lib/messaging/email/validation.test.ts b/apps/sim/lib/messaging/email/validation.test.ts index 53b45b092d..d8cbe848c7 100644 --- a/apps/sim/lib/messaging/email/validation.test.ts +++ b/apps/sim/lib/messaging/email/validation.test.ts @@ -10,6 +10,15 @@ vi.mock('@sim/logger', () => ({ }), })) +vi.mock('dns', () => ({ + resolveMx: ( + _domain: string, + callback: (err: Error | null, addresses: { exchange: string; priority: number }[]) => void + ) => { + callback(null, [{ exchange: 'mail.example.com', priority: 10 }]) + }, +})) + describe('Email Validation', () => { describe('validateEmail', () => { it.concurrent('should validate a correct email', async () => { From 37c13c8f85b810ecf59c7bd26612cdcb17cb156c Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Fri, 9 Jan 2026 13:48:27 -0800 Subject: [PATCH 30/35] improvement(preview): ui/ux --- apps/sim/app/templates/[id]/template.tsx | 1 - .../templates/components/template-card.tsx | 1 - .../execution-snapshot/execution-snapshot.tsx | 30 +- .../templates/components/template-card.tsx | 1 - .../components/general/general.tsx | 52 +- .../components/template/template.tsx | 2 - .../components/workflow-block/types.ts | 2 + .../components/workflow-block/utils.ts | 1 + .../workflow-edge/workflow-edge.tsx | 5 +- .../w/[workflowId]/hooks/use-block-visual.ts | 9 +- .../w/[workflowId]/utils/block-ring-utils.ts | 10 +- .../components/block-details-sidebar.tsx | 642 ++++++++++-------- .../w/components/preview/components/block.tsx | 68 +- .../w/components/preview/index.ts | 2 +- .../w/components/preview/preview.tsx | 111 ++- 15 files changed, 588 insertions(+), 349 deletions(-) diff --git a/apps/sim/app/templates/[id]/template.tsx b/apps/sim/app/templates/[id]/template.tsx index fe26fd1558..ac09e5af9d 100644 --- a/apps/sim/app/templates/[id]/template.tsx +++ b/apps/sim/app/templates/[id]/template.tsx @@ -332,7 +332,6 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template return ( (null) + const autoSelectedForExecutionRef = useRef(null) const [isMenuOpen, setIsMenuOpen] = useState(false) const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) @@ -146,12 +148,21 @@ export function ExecutionSnapshot({ return blockExecutionMap }, [traceSpans]) - useEffect(() => { - setPinnedBlockId(null) - }, [executionId]) - const workflowState = data?.workflowState as WorkflowState | undefined + // Auto-select the leftmost block once when data loads for a new executionId + useEffect(() => { + if ( + workflowState && + !isMigratedWorkflowState(workflowState) && + autoSelectedForExecutionRef.current !== executionId + ) { + autoSelectedForExecutionRef.current = executionId + const leftmostId = getLeftmostBlockId(workflowState) + setPinnedBlockId(leftmostId) + } + }, [executionId, workflowState]) + const renderContent = () => { if (isLoading) { return ( @@ -218,23 +229,26 @@ export function ExecutionSnapshot({
    { - setPinnedBlockId((prev) => (prev === blockId ? null : blockId)) + setPinnedBlockId(blockId) }} onNodeContextMenu={handleNodeContextMenu} + onPaneClick={() => setPinnedBlockId(null)} cursorStyle='pointer' executedBlocks={blockExecutions} + selectedBlockId={pinnedBlockId} + lightweight />
    {pinnedBlockId && workflowState.blocks[pinnedBlockId] && ( @@ -297,7 +311,7 @@ export function ExecutionSnapshot({ Workflow State - {renderContent()} + {renderContent()} {canvasContextMenu} diff --git a/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx b/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx index 730c82e54d..f5c1fd0630 100644 --- a/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/templates/components/template-card.tsx @@ -210,7 +210,6 @@ function TemplateCardInner({ {normalizedState && isInView ? ( (null) + const hasAutoSelectedRef = useRef(false) const [versionToLoad, setVersionToLoad] = useState(null) const [versionToPromote, setVersionToPromote] = useState(null) @@ -131,6 +133,19 @@ export function GeneralDeploy({ const hasDeployedData = deployedState && Object.keys(deployedState.blocks || {}).length > 0 const showLoadingSkeleton = isLoadingDeployedState && !hasDeployedData + // Auto-select the leftmost block once when expanded preview opens + useEffect(() => { + if (showExpandedPreview && workflowToShow && !hasAutoSelectedRef.current) { + hasAutoSelectedRef.current = true + const leftmostId = getLeftmostBlockId(workflowToShow) + setExpandedSelectedBlockId(leftmostId) + } + // Reset when modal closes + if (!showExpandedPreview) { + hasAutoSelectedRef.current = false + } + }, [showExpandedPreview, workflowToShow]) + if (showLoadingSkeleton) { return (
    @@ -186,7 +201,7 @@ export function GeneralDeploy({
    { if (e.ctrlKey || e.metaKey) return e.stopPropagation() @@ -194,28 +209,28 @@ export function GeneralDeploy({ > {workflowToShow ? ( <> - +
    + +
    - Expand preview + See preview ) : ( @@ -316,16 +331,15 @@ export function GeneralDeploy({
    { - setExpandedSelectedBlockId( - expandedSelectedBlockId === blockId ? null : blockId - ) + setExpandedSelectedBlockId(blockId) }} - cursorStyle='pointer' + onPaneClick={() => setExpandedSelectedBlockId(null)} + selectedBlockId={expandedSelectedBlockId} + lightweight />
    {expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx index 3bd4301250..dd7cdeee9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/template/template.tsx @@ -488,7 +488,6 @@ const OGCaptureContainer = forwardRef((_, ref) => { > blockState?: any } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts index 1239ec3968..be9c855c95 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts @@ -32,6 +32,7 @@ export function shouldSkipBlockRender( prevProps.data.isActive === nextProps.data.isActive && prevProps.data.isPending === nextProps.data.isPending && prevProps.data.isPreview === nextProps.data.isPreview && + prevProps.data.isPreviewSelected === nextProps.data.isPreviewSelected && prevProps.data.config === nextProps.data.config && prevProps.data.subBlockValues === nextProps.data.subBlockValues && prevProps.data.blockState === nextProps.data.blockState && diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index 2c2cdb00ce..b4e7ca1f3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -95,7 +95,8 @@ const WorkflowEdgeComponent = ({ } else if (edgeDiffStatus === 'new') { color = 'var(--brand-tertiary)' } else if (edgeRunStatus === 'success') { - color = 'var(--border-success)' + // Use green for preview mode, default for canvas execution + color = previewExecutionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)' } else if (edgeRunStatus === 'error') { color = 'var(--text-error)' } @@ -117,7 +118,7 @@ const WorkflowEdgeComponent = ({ strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined, opacity, } - }, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus]) + }, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus, previewExecutionStatus]) return ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts index 82795fc99e..1955f4c0e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts @@ -22,13 +22,14 @@ interface UseBlockVisualProps { /** * Provides visual state and interaction handlers for workflow blocks. * Computes ring styling based on execution, diff, deletion, and run path states. - * In preview mode, all interactive and execution-related visual states are disabled. + * In preview mode, uses isPreviewSelected for selection highlighting. * * @param props - The hook properties * @returns Visual state, click handler, and ring styling for the block */ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVisualProps) { const isPreview = data.isPreview ?? false + const isPreviewSelected = data.isPreviewSelected ?? false const currentWorkflow = useCurrentWorkflow() const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) @@ -40,7 +41,8 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis isDeletedBlock, } = useBlockState(blockId, currentWorkflow, data) - const isActive = isPreview ? false : blockIsActive + // In preview mode, use isPreviewSelected for selection state + const isActive = isPreview ? isPreviewSelected : blockIsActive const lastRunPath = useExecutionStore((state) => state.lastRunPath) const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId) @@ -61,8 +63,9 @@ export function useBlockVisual({ blockId, data, isPending = false }: UseBlockVis isDeletedBlock: isPreview ? false : isDeletedBlock, diffStatus: isPreview ? undefined : diffStatus, runPathStatus, + isPreviewSelection: isPreview && isPreviewSelected, }), - [isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview] + [isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreview, isPreviewSelected] ) return { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts index e6f081b22f..1490d6040b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts @@ -10,6 +10,7 @@ export interface BlockRingOptions { isDeletedBlock: boolean diffStatus: BlockDiffStatus runPathStatus: BlockRunPathStatus + isPreviewSelection?: boolean } /** @@ -20,7 +21,8 @@ export function getBlockRingStyles(options: BlockRingOptions): { hasRing: boolean ringClassName: string } { - const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus } = options + const { isActive, isPending, isDeletedBlock, diffStatus, runPathStatus, isPreviewSelection } = + options const hasRing = isActive || @@ -31,8 +33,12 @@ export function getBlockRingStyles(options: BlockRingOptions): { !!runPathStatus const ringClassName = cn( + // 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 && 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse', + isActive && + !isPreviewSelection && + 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse', // Non-active states use standard ring utilities !isActive && hasRing && 'ring-[1.75px]', // Pending state: warning ring diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx index 0f559f46a9..cbc6689cc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block-details-sidebar.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowDown, ArrowUp, ChevronDown as ChevronDownIcon, X } from 'lucide-react' +import { ArrowDown, ArrowUp, ChevronDown as ChevronDownIcon, ChevronUp, X } from 'lucide-react' import { ReactFlowProvider } from 'reactflow' import { Badge, Button, ChevronDown, Code, Input } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' @@ -265,6 +265,16 @@ interface ConnectionsSectionProps { workflowVars: ResolvedVariable[] envVars: ResolvedVariable[] onContextMenu?: (e: React.MouseEvent, value: string) => void + /** Height of the connections section */ + height: number + /** Whether the section is being resized */ + isResizing: boolean + /** Whether the connections are at minimum height (collapsed) */ + isAtMinHeight: boolean + /** Handler for resize mouse down */ + onResizeMouseDown: (e: React.MouseEvent) => void + /** Handler for toggling collapsed state */ + onToggleCollapsed: () => void } /** @@ -275,8 +285,12 @@ function ConnectionsSection({ workflowVars, envVars, onContextMenu, + height, + isResizing, + isAtMinHeight, + onResizeMouseDown, + onToggleCollapsed, }: ConnectionsSectionProps) { - const [isCollapsed, setIsCollapsed] = useState(false) const [expandedBlocks, setExpandedBlocks] = useState>(new Set()) const [expandedVariables, setExpandedVariables] = useState(true) const [expandedEnvVars, setExpandedEnvVars] = useState(true) @@ -308,205 +322,213 @@ function ConnectionsSection({ } return ( -
    +
    + {/* Resize Handle */} +
    +
    +
    + {/* Header with Chevron */}
    setIsCollapsed(!isCollapsed)} + onClick={onToggleCollapsed} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - setIsCollapsed(!isCollapsed) + onToggleCollapsed() } }} role='button' tabIndex={0} - aria-label={isCollapsed ? 'Expand connections' : 'Collapse connections'} + aria-label={isAtMinHeight ? 'Expand connections' : 'Collapse connections'} > -
    Connections
    {/* Content - styled like ConnectionBlocks */} - {!isCollapsed && ( -
    - {connections.map((connection) => { - const blockConfig = getBlock(connection.blockType) - const Icon = blockConfig?.icon - const bgColor = blockConfig?.bgColor || '#6B7280' - const isExpanded = expandedBlocks.has(connection.blockId) - const hasFields = connection.fields.length > 0 - - return ( -
    - {/* Block header - styled like ConnectionItem */} +
    + {connections.map((connection) => { + const blockConfig = getBlock(connection.blockType) + const Icon = blockConfig?.icon + const bgColor = blockConfig?.bgColor || '#6B7280' + const isExpanded = expandedBlocks.has(connection.blockId) + const hasFields = connection.fields.length > 0 + + return ( +
    + {/* Block header - styled like ConnectionItem */} +
    hasFields && toggleBlock(connection.blockId)} + >
    hasFields && toggleBlock(connection.blockId)} + className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]' + style={{ background: bgColor }} > -
    - {Icon && ( - - )} -
    - - {connection.blockName} - - {hasFields && ( - )}
    - - {/* Fields - styled like FieldItem but showing resolved values */} - {isExpanded && hasFields && ( -
    -
    - {connection.fields.map((field) => ( -
    handleValueContextMenu(e, field.value)} - > - - {field.path} - - - {field.value} - -
    - ))} -
    - )} -
    - ) - })} - - {/* Workflow Variables */} - {workflowVars.length > 0 && ( -
    -
    setExpandedVariables(!expandedVariables)} - > -
    - V -
    - Variables + {connection.blockName} - + {hasFields && ( + + )}
    - {expandedVariables && ( + + {/* Fields - styled like FieldItem but showing resolved values */} + {isExpanded && hasFields && (
    - {workflowVars.map((v) => ( + {connection.fields.map((field) => (
    handleValueContextMenu(e, v.value)} + onContextMenu={(e) => handleValueContextMenu(e, field.value)} > - - {v.name} + + {field.path} - {v.value} + {field.value}
    ))}
    )}
    - )} - - {/* Environment Variables */} - {envVars.length > 0 && ( -
    -
    setExpandedEnvVars(!expandedEnvVars)} + ) + })} + + {/* Workflow Variables */} + {workflowVars.length > 0 && ( +
    +
    setExpandedVariables(!expandedVariables)} + > +
    + V +
    + -
    - E -
    - - Environment Variables - - + Variables +
    + +
    + {expandedVariables && ( +
    +
    + {workflowVars.map((v) => ( +
    handleValueContextMenu(e, v.value)} + > + + {v.name} + + {v.value} +
    + ))}
    - {expandedEnvVars && ( -
    -
    - {envVars.map((v) => ( -
    - - {v.name} - - - {v.value} - -
    - ))} -
    - )} + )} +
    + )} + + {/* Environment Variables */} + {envVars.length > 0 && ( +
    +
    setExpandedEnvVars(!expandedEnvVars)} + > +
    + E +
    + + Environment Variables + +
    - )} -
    - )} + {expandedEnvVars && ( +
    +
    + {envVars.map((v) => ( +
    + + {v.name} + + {v.value} +
    + ))} +
    + )} +
    + )} +
    ) } @@ -562,6 +584,13 @@ function formatDuration(ms: number): string { return `${(ms / 1000).toFixed(2)}s` } +/** Minimum height for the connections section (header only) */ +const MIN_CONNECTIONS_HEIGHT = 30 +/** Maximum height for the connections section */ +const MAX_CONNECTIONS_HEIGHT = 300 +/** Default height for the connections section */ +const DEFAULT_CONNECTIONS_HEIGHT = 150 + /** * Readonly sidebar panel showing block configuration using SubBlock components. */ @@ -584,6 +613,13 @@ function BlockDetailsSidebarContent({ const subBlockValues = block.subBlocks || {} const contentRef = useRef(null) + const subBlocksRef = useRef(null) + + // Connections resize state + const [connectionsHeight, setConnectionsHeight] = useState(DEFAULT_CONNECTIONS_HEIGHT) + const [isResizing, setIsResizing] = useState(false) + const startYRef = useRef(0) + const startHeightRef = useRef(0) const { wrapText, @@ -659,6 +695,62 @@ function BlockDetailsSidebarContent({ } }, [contextMenuData.content]) + /** + * Handles mouse down event on the resize handle to initiate resizing + */ + const handleConnectionsResizeMouseDown = useCallback( + (e: React.MouseEvent) => { + setIsResizing(true) + startYRef.current = e.clientY + startHeightRef.current = connectionsHeight + }, + [connectionsHeight] + ) + + /** + * Toggle connections collapsed state + */ + const toggleConnectionsCollapsed = useCallback(() => { + setConnectionsHeight((prev) => + prev <= MIN_CONNECTIONS_HEIGHT ? DEFAULT_CONNECTIONS_HEIGHT : MIN_CONNECTIONS_HEIGHT + ) + }, []) + + /** + * Sets up resize event listeners during resize operations + */ + useEffect(() => { + if (!isResizing) return + + const handleMouseMove = (e: MouseEvent) => { + const deltaY = startYRef.current - e.clientY // Inverted because we're resizing from bottom up + let newHeight = startHeightRef.current + deltaY + + // Clamp height between fixed min and max for stable behavior + newHeight = Math.max(MIN_CONNECTIONS_HEIGHT, Math.min(MAX_CONNECTIONS_HEIGHT, newHeight)) + setConnectionsHeight(newHeight) + } + + const handleMouseUp = () => { + setIsResizing(false) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + document.body.style.cursor = 'ns-resize' + document.body.style.userSelect = 'none' + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + }, [isResizing]) + + // Determine if connections are at minimum height (collapsed state) + const isConnectionsAtMinHeight = connectionsHeight <= MIN_CONNECTIONS_HEIGHT + 5 + const blockNameToId = useMemo(() => { const map = new Map() if (workflowBlocks) { @@ -778,8 +870,8 @@ function BlockDetailsSidebarContent({ if (!blockConfig) { return ( -
    -
    +
    +
    {block.name || 'Unknown Block'} @@ -809,9 +901,9 @@ function BlockDetailsSidebarContent({ : 'gray' return ( -
    +
    {/* Header - styled like editor */} -
    +
    - {/* Scrollable content */} -
    - {/* Not Executed Banner - shown when in execution mode but block wasn't executed */} - {isExecutionMode && !executionData && ( -
    -
    - - Not Executed - -
    -
    - )} - - {/* Execution Input/Output (if provided) */} - {executionData && - (executionData.input !== undefined || executionData.output !== undefined) ? ( -
    - {/* Execution Status & Duration Header */} - {(executionData.status || executionData.durationMs !== undefined) && ( -
    - {executionData.status && ( - - {executionData.status} + {/* Content area */} +
    + {/* Subblocks Section */} +
    +
    + {/* Not Executed Banner - shown when in execution mode but block wasn't executed */} + {isExecutionMode && !executionData && ( +
    +
    + + Not Executed - )} - {executionData.durationMs !== undefined && ( - - {formatDuration(executionData.durationMs)} - - )} +
    )} - {/* Divider between Status/Duration and Input/Output */} - {(executionData.status || executionData.durationMs !== undefined) && - (executionData.input !== undefined || executionData.output !== undefined) && ( -
    - )} + {/* Execution Input/Output (if provided) */} + {executionData && + (executionData.input !== undefined || executionData.output !== undefined) ? ( +
    + {/* Execution Status & Duration Header */} + {(executionData.status || executionData.durationMs !== undefined) && ( +
    + {executionData.status && ( + + {executionData.status} + + )} + {executionData.durationMs !== undefined && ( + + {formatDuration(executionData.durationMs)} + + )} +
    + )} - {/* Input Section */} - {executionData.input !== undefined && ( - - )} + {/* Divider between Status/Duration and Input/Output */} + {(executionData.status || executionData.durationMs !== undefined) && + (executionData.input !== undefined || executionData.output !== undefined) && ( +
    + )} - {/* Divider between Input and Output */} - {executionData.input !== undefined && executionData.output !== undefined && ( -
    - )} + {/* Input Section */} + {executionData.input !== undefined && ( + + )} - {/* Output Section */} - {executionData.output !== undefined && ( - - )} -
    - ) : null} + {/* Divider between Input and Output */} + {executionData.input !== undefined && executionData.output !== undefined && ( +
    + )} - {/* Subblock Values - Using SubBlock components in preview mode */} -
    - {/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */} - - {visibleSubBlocks.length > 0 ? ( -
    - {visibleSubBlocks.map((subBlockConfig, index) => ( -
    handleSubblockContextMenu(e, subBlockConfig)} - > - - {index < visibleSubBlocks.length - 1 && ( -
    -
    0 ? ( +
    + {visibleSubBlocks.map((subBlockConfig, index) => ( +
    handleSubblockContextMenu(e, subBlockConfig)} + > + + {index < visibleSubBlocks.length - 1 && ( +
    +
    +
    + )}
    - )} + ))}
    - ))} -
    - ) : ( -
    -

    - No configurable fields for this block. -

    + ) : ( +
    +

    + No configurable fields for this block. +

    +
    + )}
    - )} +
    - {/* Connections Section */} - openContextMenu(e, value, true)} - /> + {/* Connections Section - Only show when there are connections */} + {(resolvedConnections.length > 0 || + resolvedWorkflowVars.length > 0 || + resolvedEnvVars.length > 0) && ( + openContextMenu(e, value, true)} + height={connectionsHeight} + isResizing={isResizing} + isAtMinHeight={isConnectionsAtMinHeight} + onResizeMouseDown={handleConnectionsResizeMouseDown} + onToggleCollapsed={toggleConnectionsCollapsed} + /> + )}
    {/* Search Overlay */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx index 423ad95032..5725ec2fb2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/block.tsx @@ -5,12 +5,19 @@ import { Handle, type NodeProps, Position } from 'reactflow' import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' import { getBlock } from '@/blocks' +/** Execution status for blocks in preview mode */ +type ExecutionStatus = 'success' | 'error' | 'not-executed' + interface WorkflowPreviewBlockData { type: string name: string isTrigger?: boolean horizontalHandles?: boolean enabled?: boolean + /** Whether this block is selected in preview mode */ + isPreviewSelected?: boolean + /** Execution status for highlighting error/success states */ + executionStatus?: ExecutionStatus } /** @@ -21,18 +28,20 @@ interface WorkflowPreviewBlockData { * Used in template cards and other preview contexts for performance. */ function WorkflowPreviewBlockInner({ data }: NodeProps) { - const { type, name, isTrigger = false, horizontalHandles = false, enabled = true } = data + const { + type, + name, + isTrigger = false, + horizontalHandles = false, + enabled = true, + isPreviewSelected = false, + executionStatus, + } = data const blockConfig = getBlock(type) - if (!blockConfig) { - return null - } - - const IconComponent = blockConfig.icon - const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger const visibleSubBlocks = useMemo(() => { - if (!blockConfig.subBlocks) return [] + if (!blockConfig?.subBlocks) return [] return blockConfig.subBlocks.filter((subBlock) => { if (subBlock.hidden) return false @@ -41,7 +50,14 @@ function WorkflowPreviewBlockInner({ data }: NodeProps if (subBlock.mode === 'advanced') return false return true }) - }, [blockConfig.subBlocks]) + }, [blockConfig?.subBlocks]) + + if (!blockConfig) { + return null + } + + const IconComponent = blockConfig.icon + const isStarterOrTrigger = blockConfig.category === 'triggers' || type === 'starter' || isTrigger const hasSubBlocks = visibleSubBlocks.length > 0 const showErrorRow = !isStarterOrTrigger @@ -49,8 +65,24 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]' const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]' + const hasError = executionStatus === 'error' + const hasSuccess = executionStatus === 'success' + return (
    + {/* Selection ring overlay (takes priority over execution rings) */} + {isPreviewSelected && ( +
    + )} + {/* Success ring overlay (only shown if not selected) */} + {!isPreviewSelected && hasSuccess && ( +
    + )} + {/* Error ring overlay (only shown if not selected) */} + {!isPreviewSelected && hasError && ( +
    + )} + {/* Target handle - not shown for triggers/starters */} {!isStarterOrTrigger && ( ) } -export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner) +function shouldSkipPreviewBlockRender( + prevProps: NodeProps, + nextProps: NodeProps +): boolean { + return ( + prevProps.id === nextProps.id && + prevProps.data.type === nextProps.data.type && + prevProps.data.name === nextProps.data.name && + prevProps.data.isTrigger === nextProps.data.isTrigger && + prevProps.data.horizontalHandles === nextProps.data.horizontalHandles && + prevProps.data.enabled === nextProps.data.enabled && + prevProps.data.isPreviewSelected === nextProps.data.isPreviewSelected && + prevProps.data.executionStatus === nextProps.data.executionStatus + ) +} + +export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts index 4c959e26d3..89c096d6eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts @@ -1,2 +1,2 @@ export { BlockDetailsSidebar } from './components/block-details-sidebar' -export { WorkflowPreview } from './preview' +export { getLeftmostBlockId, WorkflowPreview } from './preview' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx index a5a9368854..853ade0fa6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useRef } from 'react' import ReactFlow, { ConnectionLineType, type Edge, @@ -25,12 +25,33 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowPreview') +/** + * Finds the leftmost block ID from a workflow state. + * Returns the block with the smallest x position, excluding subflow containers (loop/parallel). + */ +export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null { + if (!workflowState?.blocks) return null + + let leftmostId: string | null = null + let minX = Number.POSITIVE_INFINITY + + for (const [blockId, block] of Object.entries(workflowState.blocks)) { + if (!block || block.type === 'loop' || block.type === 'parallel') continue + const x = block.position?.x ?? Number.POSITIVE_INFINITY + if (x < minX) { + minX = x + leftmostId = blockId + } + } + + return leftmostId +} + /** Execution status for edges/nodes in the preview */ type ExecutionStatus = 'success' | 'error' | 'not-executed' interface WorkflowPreviewProps { workflowState: WorkflowState - showSubBlocks?: boolean className?: string height?: string | number width?: string | number @@ -41,12 +62,16 @@ interface WorkflowPreviewProps { onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void /** Callback when a node is right-clicked */ onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void + /** Callback when the canvas (empty area) is clicked */ + onPaneClick?: () => void /** Use lightweight blocks for better performance in template cards */ lightweight?: boolean /** Cursor style to show when hovering the canvas */ cursorStyle?: 'default' | 'pointer' | 'grab' /** Map of executed block IDs to their status for highlighting the execution path */ executedBlocks?: Record + /** Currently selected block ID for highlighting */ + selectedBlockId?: string | null } /** @@ -75,45 +100,49 @@ const edgeTypes: EdgeTypes = { } interface FitViewOnChangeProps { - nodes: Node[] + nodeIds: string fitPadding: number } /** - * Helper component that calls fitView when nodes change. + * Helper component that calls fitView when the set of nodes changes. + * Only triggers on actual node additions/removals, not on selection changes. * Must be rendered inside ReactFlowProvider. */ -function FitViewOnChange({ nodes, fitPadding }: FitViewOnChangeProps) { +function FitViewOnChange({ nodeIds, fitPadding }: FitViewOnChangeProps) { const { fitView } = useReactFlow() + const hasFittedRef = useRef(false) useEffect(() => { - if (nodes.length > 0) { + if (nodeIds.length > 0 && !hasFittedRef.current) { + hasFittedRef.current = true // Small delay to ensure nodes are rendered before fitting const timeoutId = setTimeout(() => { fitView({ padding: fitPadding, duration: 200 }) }, 50) return () => clearTimeout(timeoutId) } - }, [nodes, fitPadding, fitView]) + }, [nodeIds, fitPadding, fitView]) return null } export function WorkflowPreview({ workflowState, - showSubBlocks = true, className, height = '100%', width = '100%', - isPannable = false, + isPannable = true, defaultPosition, defaultZoom = 0.8, fitPadding = 0.25, onNodeClick, onNodeContextMenu, + onPaneClick, lightweight = false, cursorStyle = 'grab', executedBlocks, + selectedBlockId, }: WorkflowPreviewProps) { const nodeTypes = lightweight ? lightweightNodeTypes : fullNodeTypes const isValidWorkflowState = workflowState?.blocks && workflowState.edges @@ -202,6 +231,24 @@ export function WorkflowPreview({ return } + const isSelected = selectedBlockId === blockId + + let lightweightExecutionStatus: ExecutionStatus | undefined + if (executedBlocks) { + const blockExecution = executedBlocks[blockId] + if (blockExecution) { + if (blockExecution.status === 'error') { + lightweightExecutionStatus = 'error' + } else if (blockExecution.status === 'success') { + lightweightExecutionStatus = 'success' + } else { + lightweightExecutionStatus = 'not-executed' + } + } else { + lightweightExecutionStatus = 'not-executed' + } + } + nodeArray.push({ id: blockId, type: 'workflowBlock', @@ -213,6 +260,8 @@ export function WorkflowPreview({ isTrigger: block.triggerMode === true, horizontalHandles: block.horizontalHandles ?? false, enabled: block.enabled ?? true, + isPreviewSelected: isSelected, + executionStatus: lightweightExecutionStatus, }, }) return @@ -284,15 +333,13 @@ export function WorkflowPreview({ } } + const isSelected = selectedBlockId === blockId + nodeArray.push({ id: blockId, type: nodeType, position: absolutePosition, draggable: false, - className: - executionStatus && executionStatus !== 'not-executed' - ? `execution-${executionStatus}` - : undefined, data: { type: block.type, config: blockConfig, @@ -300,6 +347,7 @@ export function WorkflowPreview({ blockState: block, canEdit: false, isPreview: true, + isPreviewSelected: isSelected, subBlockValues: block.subBlocks ?? {}, executionStatus, }, @@ -311,11 +359,11 @@ export function WorkflowPreview({ blocksStructure, loopsStructure, parallelsStructure, - showSubBlocks, workflowState.blocks, isValidWorkflowState, lightweight, executedBlocks, + selectedBlockId, ]) const edges: Edge[] = useMemo(() => { @@ -328,9 +376,8 @@ export function WorkflowPreview({ const targetExecuted = executedBlocks[edge.target] if (sourceExecuted && targetExecuted) { - if (targetExecuted.status === 'error') { - executionStatus = 'error' - } else if (sourceExecuted.status === 'success' && targetExecuted.status === 'success') { + // Edge is success if source succeeded and target was executed (even if target errored) + if (sourceExecuted.status === 'success') { executionStatus = 'success' } else { executionStatus = 'not-executed' @@ -347,6 +394,8 @@ export function WorkflowPreview({ sourceHandle: edge.sourceHandle, targetHandle: edge.targetHandle, data: executionStatus ? { executionStatus } : undefined, + // Raise executed edges above default edges + zIndex: executionStatus === 'success' ? 10 : 0, } }) }, [edgesStructure, workflowState.edges, isValidWorkflowState, executedBlocks]) @@ -371,20 +420,19 @@ export function WorkflowPreview({
    - +
    ) From f00a7a08062f8497e089f820f1d6a2a7b556a904 Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Fri, 9 Jan 2026 14:09:00 -0800 Subject: [PATCH 31/35] fix(preview): subflows --- .../execution-snapshot/execution-snapshot.tsx | 2 + .../components/general/general.tsx | 2 + .../components/subflows/subflow-node.tsx | 10 +- .../components/block-details-sidebar.tsx | 244 +++++++++++++++++- .../components/preview/components/subflow.tsx | 57 ++-- .../w/components/preview/preview.tsx | 97 ++++++- 6 files changed, 378 insertions(+), 34 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx index 9f56aceba3..49d0e316c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx @@ -258,6 +258,8 @@ export function ExecutionSnapshot({ allBlockExecutions={blockExecutions} workflowBlocks={workflowState.blocks} workflowVariables={workflowState.variables} + loops={workflowState.loops} + parallels={workflowState.parallels} isExecutionMode onClose={() => setPinnedBlockId(null)} /> diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx index e756c55530..fecc1327d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/general.tsx @@ -346,6 +346,8 @@ export function GeneralDeploy({ setExpandedSelectedBlockId(null)} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx index e37f4dd88d..fd1aa196ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx @@ -47,6 +47,8 @@ export interface SubflowNodeData { parentId?: string extent?: 'parent' isPreview?: boolean + /** Whether this subflow is selected in preview mode */ + isPreviewSelected?: boolean kind: 'loop' | 'parallel' name?: string } @@ -123,15 +125,17 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps } +/** + * Configuration for subflow types (loop and parallel) - matches use-subflow-editor.ts + */ +const SUBFLOW_CONFIG = { + loop: { + typeLabels: { + for: 'For Loop', + forEach: 'For Each', + while: 'While Loop', + doWhile: 'Do While Loop', + }, + maxIterations: 1000, + }, + parallel: { + typeLabels: { + count: 'Parallel Count', + collection: 'Parallel Each', + }, + maxIterations: 20, + }, +} as const + +interface SubflowConfigDisplayProps { + block: BlockState + loop?: Loop + parallel?: Parallel +} + +/** + * Display subflow (loop/parallel) configuration in preview mode. + * Matches the exact UI structure of SubflowEditor. + */ +function SubflowConfigDisplay({ block, loop, parallel }: SubflowConfigDisplayProps) { + const isLoop = block.type === 'loop' + const config = isLoop ? SUBFLOW_CONFIG.loop : SUBFLOW_CONFIG.parallel + + // Determine current type + const currentType = isLoop + ? loop?.loopType || (block.data?.loopType as string) || 'for' + : parallel?.parallelType || (block.data?.parallelType as string) || 'count' + + // Build type options for combobox - matches SubflowEditor + const typeOptions = Object.entries(config.typeLabels).map(([value, label]) => ({ + value, + label, + })) + + // Determine mode + const isCountMode = currentType === 'for' || currentType === 'count' + const isConditionMode = currentType === 'while' || currentType === 'doWhile' + + // Get iterations value + const iterations = isLoop + ? (loop?.iterations ?? (block.data?.count as number) ?? 5) + : (parallel?.count ?? (block.data?.count as number) ?? 1) + + // Get collection/condition value + const getEditorValue = (): string => { + if (isConditionMode && isLoop) { + if (currentType === 'while') { + return loop?.whileCondition || (block.data?.whileCondition as string) || '' + } + return loop?.doWhileCondition || (block.data?.doWhileCondition as string) || '' + } + + if (isLoop) { + const items = loop?.forEachItems ?? block.data?.collection + return typeof items === 'string' ? items : JSON.stringify(items) || '' + } + + const distribution = parallel?.distribution ?? block.data?.collection + return typeof distribution === 'string' ? distribution : JSON.stringify(distribution) || '' + } + + const editorValue = getEditorValue() + + // Get label for configuration field - matches SubflowEditor exactly + const getConfigLabel = (): string => { + if (isCountMode) { + return `${isLoop ? 'Loop' : 'Parallel'} Iterations` + } + if (isConditionMode) { + return 'While Condition' + } + return `${isLoop ? 'Collection' : 'Parallel'} Items` + } + + return ( +
    + {/* Type Selection - matches SubflowEditor */} +
    + + {}} + disabled + placeholder='Select type...' + /> +
    + + {/* Dashed Line Separator - matches SubflowEditor */} +
    +
    +
    + + {/* Configuration - matches SubflowEditor */} +
    + + + {isCountMode ? ( +
    + {}} + disabled + className='mb-[4px]' + /> +
    + Enter a number between 1 and {config.maxIterations} +
    +
    + ) : ( +
    + + + + {isConditionMode ? ' < 10' : "['item1', 'item2', 'item3']"} + +
    + {editorValue || ( + + {isConditionMode ? ' < 10' : "['item1', 'item2', 'item3']"} + + )} +
    +
    +
    +
    + )} +
    +
    + ) +} + interface ExecutionData { input?: unknown output?: unknown @@ -570,6 +737,10 @@ interface BlockDetailsSidebarProps { workflowBlocks?: Record /** Workflow variables for resolving variable references */ workflowVariables?: Record + /** Loop configurations for subflow blocks */ + loops?: Record + /** Parallel configurations for subflow blocks */ + parallels?: Record /** When true, shows "Not Executed" badge if no executionData is provided */ isExecutionMode?: boolean /** Optional close handler - if not provided, no close button is shown */ @@ -600,6 +771,8 @@ function BlockDetailsSidebarContent({ allBlockExecutions, workflowBlocks, workflowVariables, + loops, + parallels, isExecutionMode = false, onClose, }: BlockDetailsSidebarProps) { @@ -868,6 +1041,71 @@ function BlockDetailsSidebarContent({ }) }, [extractedRefs.envVars]) + // Check if this is a subflow block (loop or parallel) + const isSubflow = block.type === 'loop' || block.type === 'parallel' + const loopConfig = block.type === 'loop' ? loops?.[block.id] : undefined + const parallelConfig = block.type === 'parallel' ? parallels?.[block.id] : undefined + + // Handle subflow blocks + if (isSubflow) { + const isLoop = block.type === 'loop' + const SubflowIcon = isLoop ? RepeatIcon : SplitIcon + const subflowBgColor = isLoop ? '#2FB3FF' : '#FEE12B' + const subflowName = block.name || (isLoop ? 'Loop' : 'Parallel') + + return ( +
    + {/* Header - styled like subflow header */} +
    +
    + +
    + + {subflowName} + + {onClose && ( + + )} +
    + + {/* Subflow Configuration */} +
    +
    +
    + {/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */} + + +
    +
    +
    +
    + ) + } + if (!blockConfig) { return (
    diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx index 67befddbda..99a0e8ca9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/subflow.tsx @@ -10,6 +10,8 @@ interface WorkflowPreviewSubflowData { width?: number height?: number kind: 'loop' | 'parallel' + /** Whether this subflow is selected in preview mode */ + isPreviewSelected?: boolean } /** @@ -19,7 +21,7 @@ interface WorkflowPreviewSubflowData { * Used in template cards and other preview contexts for performance. */ function WorkflowPreviewSubflowInner({ data }: NodeProps) { - const { name, width = 500, height = 300, kind } = data + const { name, width = 500, height = 300, kind, isPreviewSelected = false } = data const isLoop = kind === 'loop' const BlockIcon = isLoop ? RepeatIcon : SplitIcon @@ -42,6 +44,11 @@ function WorkflowPreviewSubflowInner({ data }: NodeProps + {/* Selection ring overlay */} + {isPreviewSelected && ( +
    + )} + {/* Target handle on left (input to the subflow) */} - {/* Header - matches actual subflow header */} -
    -
    - + {/* Header - matches actual subflow header structure */} +
    +
    +
    + +
    + + {blockName} +
    - - {blockName} -
    - {/* Start handle inside - connects to first block in subflow */} -
    - Start - + {/* Content area - matches workflow structure */} +
    + {/* Subflow Start - connects to first block in subflow */} +
    + Start + +
    {/* End source handle on right (output from the subflow) */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx index 853ade0fa6..af554710cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx @@ -14,17 +14,87 @@ import 'reactflow/dist/style.css' import { createLogger } from '@sim/logger' import { cn } from '@/lib/core/utils/cn' +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block' import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node' import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' +import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block' import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow' import { getBlock } from '@/blocks' -import type { WorkflowState } from '@/stores/workflows/workflow/types' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowPreview') +/** + * Gets block dimensions for preview purposes. + * For containers, uses stored dimensions or defaults. + * For regular blocks, uses stored height or estimates based on type. + */ +function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } { + if (block.type === 'loop' || block.type === 'parallel') { + return { + width: block.data?.width + ? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH) + : CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: block.data?.height + ? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT) + : CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + } + + if (block.height) { + return { + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT), + } + } + + return estimateBlockDimensions(block.type) +} + +/** + * Calculates container dimensions based on child block positions and sizes. + * Mirrors the logic from useNodeUtilities.calculateLoopDimensions. + */ +function calculateContainerDimensions( + containerId: string, + blocks: Record +): { width: number; height: number } { + const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId) + + if (childBlocks.length === 0) { + return { + width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + } + + let maxRight = 0 + let maxBottom = 0 + + for (const child of childBlocks) { + if (!child?.position) continue + + const { width: childWidth, height: childHeight } = getPreviewBlockDimensions(child) + + maxRight = Math.max(maxRight, child.position.x + childWidth) + maxBottom = Math.max(maxBottom, child.position.y + childHeight) + } + + const width = Math.max( + CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING + ) + const height = Math.max( + CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING + ) + + return { width, height } +} + /** * Finds the leftmost block ID from a workflow state. * Returns the block with the smallest x position, excluding subflow containers (loop/parallel). @@ -216,6 +286,8 @@ export function WorkflowPreview({ if (lightweight) { if (block.type === 'loop' || block.type === 'parallel') { + const isSelected = selectedBlockId === blockId + const dimensions = calculateContainerDimensions(blockId, workflowState.blocks) nodeArray.push({ id: blockId, type: 'subflowNode', @@ -223,9 +295,10 @@ export function WorkflowPreview({ draggable: false, data: { name: block.name, - width: block.data?.width || 500, - height: block.data?.height || 300, + width: dimensions.width, + height: dimensions.height, kind: block.type as 'loop' | 'parallel', + isPreviewSelected: isSelected, }, }) return @@ -254,6 +327,8 @@ export function WorkflowPreview({ type: 'workflowBlock', position: absolutePosition, draggable: false, + // Blocks inside subflows need higher z-index to appear above the container + zIndex: block.data?.parentId ? 10 : undefined, data: { type: block.type, name: block.name, @@ -268,6 +343,8 @@ export function WorkflowPreview({ } if (block.type === 'loop') { + const isSelected = selectedBlockId === blockId + const dimensions = calculateContainerDimensions(blockId, workflowState.blocks) nodeArray.push({ id: blockId, type: 'subflowNode', @@ -278,10 +355,11 @@ export function WorkflowPreview({ data: { ...block.data, name: block.name, - width: block.data?.width || 500, - height: block.data?.height || 300, + width: dimensions.width, + height: dimensions.height, state: 'valid', isPreview: true, + isPreviewSelected: isSelected, kind: 'loop', }, }) @@ -289,6 +367,8 @@ export function WorkflowPreview({ } if (block.type === 'parallel') { + const isSelected = selectedBlockId === blockId + const dimensions = calculateContainerDimensions(blockId, workflowState.blocks) nodeArray.push({ id: blockId, type: 'subflowNode', @@ -299,10 +379,11 @@ export function WorkflowPreview({ data: { ...block.data, name: block.name, - width: block.data?.width || 500, - height: block.data?.height || 300, + width: dimensions.width, + height: dimensions.height, state: 'valid', isPreview: true, + isPreviewSelected: isSelected, kind: 'parallel', }, }) @@ -340,6 +421,8 @@ export function WorkflowPreview({ type: nodeType, position: absolutePosition, draggable: false, + // Blocks inside subflows need higher z-index to appear above the container + zIndex: block.data?.parentId ? 10 : undefined, data: { type: block.type, config: blockConfig, From b24f119dab5c6cd7c7089e04890aca22ab65a49e Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 9 Jan 2026 14:17:16 -0800 Subject: [PATCH 32/35] added batch add edges --- apps/sim/hooks/use-collaborative-workflow.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 2bd9ce819a..a9de7ffadb 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -315,6 +315,13 @@ export function useCollaborativeWorkflow() { } break } + case 'batch-add-edges': { + const { edges } = payload + if (Array.isArray(edges)) { + edges.forEach((edge: Edge) => workflowStore.addEdge(edge)) + } + break + } } } else if (target === 'subflow') { switch (operation) { From 687733db5d3513b9866eddc4ad569312c5e8254e Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 9 Jan 2026 14:20:53 -0800 Subject: [PATCH 33/35] removed recovery --- .../[workspaceId]/w/[workflowId]/workflow.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 3faa975b50..876ee009a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1788,18 +1788,6 @@ const WorkflowContent = React.memo(() => { return } - // Recovery: detect and clear invalid parent references to prevent infinite recursion - if (block.data?.parentId) { - if (block.data.parentId === block.id) { - block.data = { ...block.data, parentId: undefined, extent: undefined } - } else { - const parentBlock = blocks[block.data.parentId] - if (parentBlock?.data?.parentId === block.id) { - block.data = { ...block.data, parentId: undefined, extent: undefined } - } - } - } - // Handle container nodes differently if (block.type === 'loop' || block.type === 'parallel') { nodeArray.push({ From 80e98ddfeadac4d82a971801916d64a61f6920b5 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 9 Jan 2026 14:44:04 -0800 Subject: [PATCH 34/35] use consolidated consts for sockets operations --- apps/sim/hooks/use-collaborative-workflow.ts | 287 +++++++++++-------- apps/sim/hooks/use-undo-redo.ts | 177 ++++++------ apps/sim/socket/constants.ts | 96 +++++++ apps/sim/socket/database/operations.ts | 68 +++-- apps/sim/socket/handlers/operations.ts | 53 +++- apps/sim/socket/validation/schemas.ts | 80 +++--- apps/sim/stores/undo-redo/store.ts | 15 +- apps/sim/stores/undo-redo/types.ts | 39 +-- apps/sim/stores/undo-redo/utils.ts | 35 +-- 9 files changed, 524 insertions(+), 326 deletions(-) create mode 100644 apps/sim/socket/constants.ts diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index a9de7ffadb..ba6fda4e1c 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -6,6 +6,17 @@ import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { useSocket } from '@/app/workspace/providers/socket-provider' import { getBlock } from '@/blocks' import { useUndoRedo } from '@/hooks/use-undo-redo' +import { + BLOCK_OPERATIONS, + BLOCKS_OPERATIONS, + EDGE_OPERATIONS, + EDGES_OPERATIONS, + OPERATION_TARGETS, + SUBBLOCK_OPERATIONS, + SUBFLOW_OPERATIONS, + VARIABLE_OPERATIONS, + WORKFLOW_OPERATIONS, +} from '@/socket/constants' import { useNotificationStore } from '@/stores/notifications' import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store' import { usePanelEditorStore } from '@/stores/panel/editor/store' @@ -195,9 +206,9 @@ export function useCollaborativeWorkflow() { isApplyingRemoteChange.current = true try { - if (target === 'block') { + if (target === OPERATION_TARGETS.BLOCK) { switch (operation) { - case 'update-position': { + case BLOCK_OPERATIONS.UPDATE_POSITION: { const blockId = payload.id if (!data.timestamp) { @@ -225,22 +236,22 @@ export function useCollaborativeWorkflow() { } break } - case 'update-name': + case BLOCK_OPERATIONS.UPDATE_NAME: workflowStore.updateBlockName(payload.id, payload.name) break - case 'toggle-enabled': + case BLOCK_OPERATIONS.TOGGLE_ENABLED: workflowStore.toggleBlockEnabled(payload.id) break - case 'update-parent': + case BLOCK_OPERATIONS.UPDATE_PARENT: workflowStore.updateParentId(payload.id, payload.parentId, payload.extent) break - case 'update-advanced-mode': + case BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE: workflowStore.setBlockAdvancedMode(payload.id, payload.advancedMode) break - case 'update-trigger-mode': + case BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE: workflowStore.setBlockTriggerMode(payload.id, payload.triggerMode) break - case 'toggle-handles': { + case BLOCK_OPERATIONS.TOGGLE_HANDLES: { const currentBlock = workflowStore.blocks[payload.id] if (currentBlock && currentBlock.horizontalHandles !== payload.horizontalHandles) { workflowStore.toggleBlockHandles(payload.id) @@ -248,9 +259,9 @@ export function useCollaborativeWorkflow() { break } } - } else if (target === 'blocks') { + } else if (target === OPERATION_TARGETS.BLOCKS) { switch (operation) { - case 'batch-update-positions': { + case BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS: { const { updates } = payload if (Array.isArray(updates)) { updates.forEach(({ id, position }: { id: string; position: Position }) => { @@ -262,12 +273,12 @@ export function useCollaborativeWorkflow() { break } } - } else if (target === 'edge') { + } else if (target === OPERATION_TARGETS.EDGE) { switch (operation) { - case 'add': + case EDGE_OPERATIONS.ADD: workflowStore.addEdge(payload as Edge) break - case 'remove': { + case EDGE_OPERATIONS.REMOVE: { workflowStore.removeEdge(payload.id) const updatedBlocks = useWorkflowStore.getState().blocks @@ -288,9 +299,9 @@ export function useCollaborativeWorkflow() { break } } - } else if (target === 'edges') { + } else if (target === OPERATION_TARGETS.EDGES) { switch (operation) { - case 'batch-remove-edges': { + case EDGES_OPERATIONS.BATCH_REMOVE_EDGES: { const { ids } = payload if (Array.isArray(ids)) { ids.forEach((id: string) => { @@ -315,7 +326,7 @@ export function useCollaborativeWorkflow() { } break } - case 'batch-add-edges': { + case EDGES_OPERATIONS.BATCH_ADD_EDGES: { const { edges } = payload if (Array.isArray(edges)) { edges.forEach((edge: Edge) => workflowStore.addEdge(edge)) @@ -323,9 +334,9 @@ export function useCollaborativeWorkflow() { break } } - } else if (target === 'subflow') { + } else if (target === OPERATION_TARGETS.SUBFLOW) { switch (operation) { - case 'update': + case SUBFLOW_OPERATIONS.UPDATE: // Handle subflow configuration updates (loop/parallel type changes, etc.) if (payload.type === 'loop') { const { config } = payload @@ -358,9 +369,9 @@ export function useCollaborativeWorkflow() { } break } - } else if (target === 'variable') { + } else if (target === OPERATION_TARGETS.VARIABLE) { switch (operation) { - case 'add': + case VARIABLE_OPERATIONS.ADD: variablesStore.addVariable( { workflowId: payload.workflowId, @@ -371,7 +382,7 @@ export function useCollaborativeWorkflow() { payload.id ) break - case 'variable-update': + case VARIABLE_OPERATIONS.UPDATE: if (payload.field === 'name') { variablesStore.updateVariable(payload.variableId, { name: payload.value }) } else if (payload.field === 'value') { @@ -380,13 +391,13 @@ export function useCollaborativeWorkflow() { variablesStore.updateVariable(payload.variableId, { type: payload.value }) } break - case 'remove': + case VARIABLE_OPERATIONS.REMOVE: variablesStore.deleteVariable(payload.variableId) break } - } else if (target === 'workflow') { + } else if (target === OPERATION_TARGETS.WORKFLOW) { switch (operation) { - case 'replace-state': + case WORKFLOW_OPERATIONS.REPLACE_STATE: if (payload.state) { logger.info('Received workflow state replacement from remote user', { userId, @@ -419,9 +430,9 @@ export function useCollaborativeWorkflow() { } } - if (target === 'blocks') { + if (target === OPERATION_TARGETS.BLOCKS) { switch (operation) { - case 'batch-add-blocks': { + case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: { const { blocks, edges, @@ -489,7 +500,7 @@ export function useCollaborativeWorkflow() { logger.info('Successfully applied batch-add-blocks from remote user') break } - case 'batch-remove-blocks': { + case BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS: { const { ids } = payload logger.info('Received batch-remove-blocks from remote user', { userId, @@ -773,8 +784,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'batch-update-positions', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS, + target: OPERATION_TARGETS.BLOCKS, payload: { updates }, }, workflowId: activeWorkflowId || '', @@ -842,41 +853,46 @@ export function useCollaborativeWorkflow() { return { success: false, error: `Block name "${trimmedName}" already exists` } } - executeQueuedOperation('update-name', 'block', { id, name: trimmedName }, () => { - const result = workflowStore.updateBlockName(id, trimmedName) + executeQueuedOperation( + BLOCK_OPERATIONS.UPDATE_NAME, + OPERATION_TARGETS.BLOCK, + { id, name: trimmedName }, + () => { + const result = workflowStore.updateBlockName(id, trimmedName) - if (result.success && result.changedSubblocks.length > 0) { - logger.info('Emitting cascaded subblock updates from block rename', { - blockId: id, - newName: trimmedName, - updateCount: result.changedSubblocks.length, - }) + if (result.success && result.changedSubblocks.length > 0) { + logger.info('Emitting cascaded subblock updates from block rename', { + blockId: id, + newName: trimmedName, + updateCount: result.changedSubblocks.length, + }) - result.changedSubblocks.forEach( - ({ - blockId, - subBlockId, - newValue, - }: { - blockId: string - subBlockId: string - newValue: any - }) => { - const operationId = crypto.randomUUID() - addToQueue({ - id: operationId, - operation: { - operation: 'subblock-update', - target: 'subblock', - payload: { blockId, subblockId: subBlockId, value: newValue }, - }, - workflowId: activeWorkflowId || '', - userId: session?.user?.id || 'unknown', - }) - } - ) + result.changedSubblocks.forEach( + ({ + blockId, + subBlockId, + newValue, + }: { + blockId: string + subBlockId: string + newValue: any + }) => { + const operationId = crypto.randomUUID() + addToQueue({ + id: operationId, + operation: { + operation: SUBBLOCK_OPERATIONS.UPDATE, + target: OPERATION_TARGETS.SUBBLOCK, + payload: { blockId, subblockId: subBlockId, value: newValue }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + } + ) + } } - }) + ) return { success: true } }, @@ -905,8 +921,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'batch-toggle-enabled', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, + target: OPERATION_TARGETS.BLOCKS, payload: { blockIds: validIds, previousStates }, }, workflowId: activeWorkflowId || '', @@ -924,8 +940,11 @@ export function useCollaborativeWorkflow() { const collaborativeUpdateParentId = useCallback( (id: string, parentId: string, extent: 'parent') => { - executeQueuedOperation('update-parent', 'block', { id, parentId, extent }, () => - workflowStore.updateParentId(id, parentId, extent) + executeQueuedOperation( + BLOCK_OPERATIONS.UPDATE_PARENT, + OPERATION_TARGETS.BLOCK, + { id, parentId, extent }, + () => workflowStore.updateParentId(id, parentId, extent) ) }, [executeQueuedOperation, workflowStore] @@ -978,8 +997,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'batch-update-parent', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT, + target: OPERATION_TARGETS.BLOCKS, payload: { updates: batchUpdates.map((u) => ({ id: u.blockId, @@ -1005,8 +1024,8 @@ export function useCollaborativeWorkflow() { const newAdvancedMode = !currentBlock.advancedMode executeQueuedOperation( - 'update-advanced-mode', - 'block', + BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, + OPERATION_TARGETS.BLOCK, { id, advancedMode: newAdvancedMode }, () => workflowStore.toggleBlockAdvancedMode(id) ) @@ -1036,8 +1055,8 @@ export function useCollaborativeWorkflow() { } executeQueuedOperation( - 'update-trigger-mode', - 'block', + BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE, + OPERATION_TARGETS.BLOCK, { id, triggerMode: newTriggerMode }, () => workflowStore.toggleBlockTriggerMode(id) ) @@ -1067,8 +1086,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'batch-toggle-handles', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES, + target: OPERATION_TARGETS.BLOCKS, payload: { blockIds: validIds, previousStates }, }, workflowId: activeWorkflowId || '', @@ -1086,7 +1105,9 @@ export function useCollaborativeWorkflow() { const collaborativeAddEdge = useCallback( (edge: Edge) => { - executeQueuedOperation('add', 'edge', edge, () => workflowStore.addEdge(edge)) + executeQueuedOperation(EDGE_OPERATIONS.ADD, OPERATION_TARGETS.EDGE, edge, () => + workflowStore.addEdge(edge) + ) if (!skipEdgeRecording.current) { undoRedo.recordAddEdge(edge.id) } @@ -1119,7 +1140,7 @@ export function useCollaborativeWorkflow() { undoRedo.recordBatchRemoveEdges([edge]) } - executeQueuedOperation('remove', 'edge', { id: edgeId }, () => + executeQueuedOperation(EDGE_OPERATIONS.REMOVE, OPERATION_TARGETS.EDGE, { id: edgeId }, () => workflowStore.removeEdge(edgeId) ) }, @@ -1160,8 +1181,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'batch-remove-edges', - target: 'edges', + operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + target: OPERATION_TARGETS.EDGES, payload: { ids: validEdgeIds }, }, workflowId: activeWorkflowId || '', @@ -1206,8 +1227,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'subblock-update', - target: 'subblock', + operation: SUBBLOCK_OPERATIONS.UPDATE, + target: OPERATION_TARGETS.SUBBLOCK, payload: { blockId, subblockId, value }, }, workflowId: currentActiveWorkflowId || '', @@ -1270,8 +1291,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'subblock-update', - target: 'subblock', + operation: SUBBLOCK_OPERATIONS.UPDATE, + target: OPERATION_TARGETS.SUBBLOCK, payload: { blockId, subblockId, value }, }, workflowId: activeWorkflowId || '', @@ -1317,12 +1338,17 @@ export function useCollaborativeWorkflow() { doWhileCondition: existingDoWhileCondition ?? '', } - executeQueuedOperation('update', 'subflow', { id: loopId, type: 'loop', config }, () => { - workflowStore.updateLoopType(loopId, loopType) - workflowStore.setLoopForEachItems(loopId, existingForEachItems ?? '') - workflowStore.setLoopWhileCondition(loopId, existingWhileCondition ?? '') - workflowStore.setLoopDoWhileCondition(loopId, existingDoWhileCondition ?? '') - }) + executeQueuedOperation( + SUBFLOW_OPERATIONS.UPDATE, + OPERATION_TARGETS.SUBFLOW, + { id: loopId, type: 'loop', config }, + () => { + workflowStore.updateLoopType(loopId, loopType) + workflowStore.setLoopForEachItems(loopId, existingForEachItems ?? '') + workflowStore.setLoopWhileCondition(loopId, existingWhileCondition ?? '') + workflowStore.setLoopDoWhileCondition(loopId, existingDoWhileCondition ?? '') + } + ) }, [executeQueuedOperation, workflowStore] ) @@ -1355,8 +1381,8 @@ export function useCollaborativeWorkflow() { } executeQueuedOperation( - 'update', - 'subflow', + SUBFLOW_OPERATIONS.UPDATE, + OPERATION_TARGETS.SUBFLOW, { id: parallelId, type: 'parallel', config }, () => { workflowStore.updateParallelType(parallelId, parallelType) @@ -1390,8 +1416,11 @@ export function useCollaborativeWorkflow() { forEachItems: currentCollection, } - executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'loop', config }, () => - workflowStore.updateLoopCount(nodeId, count) + executeQueuedOperation( + SUBFLOW_OPERATIONS.UPDATE, + OPERATION_TARGETS.SUBFLOW, + { id: nodeId, type: 'loop', config }, + () => workflowStore.updateLoopCount(nodeId, count) ) } else { const currentDistribution = currentBlock.data?.collection || '' @@ -1405,8 +1434,11 @@ export function useCollaborativeWorkflow() { parallelType: currentParallelType, } - executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'parallel', config }, () => - workflowStore.updateParallelCount(nodeId, count) + executeQueuedOperation( + SUBFLOW_OPERATIONS.UPDATE, + OPERATION_TARGETS.SUBFLOW, + { id: nodeId, type: 'parallel', config }, + () => workflowStore.updateParallelCount(nodeId, count) ) } }, @@ -1451,11 +1483,16 @@ export function useCollaborativeWorkflow() { doWhileCondition: nextDoWhileCondition ?? '', } - executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'loop', config }, () => { - workflowStore.setLoopForEachItems(nodeId, nextForEachItems ?? '') - workflowStore.setLoopWhileCondition(nodeId, nextWhileCondition ?? '') - workflowStore.setLoopDoWhileCondition(nodeId, nextDoWhileCondition ?? '') - }) + executeQueuedOperation( + SUBFLOW_OPERATIONS.UPDATE, + OPERATION_TARGETS.SUBFLOW, + { id: nodeId, type: 'loop', config }, + () => { + workflowStore.setLoopForEachItems(nodeId, nextForEachItems ?? '') + workflowStore.setLoopWhileCondition(nodeId, nextWhileCondition ?? '') + workflowStore.setLoopDoWhileCondition(nodeId, nextDoWhileCondition ?? '') + } + ) } else { const currentCount = currentBlock.data?.count || 5 const currentParallelType = currentBlock.data?.parallelType || 'count' @@ -1468,8 +1505,11 @@ export function useCollaborativeWorkflow() { parallelType: currentParallelType, } - executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'parallel', config }, () => - workflowStore.updateParallelCollection(nodeId, collection) + executeQueuedOperation( + SUBFLOW_OPERATIONS.UPDATE, + OPERATION_TARGETS.SUBFLOW, + { id: nodeId, type: 'parallel', config }, + () => workflowStore.updateParallelCollection(nodeId, collection) ) } }, @@ -1478,15 +1518,20 @@ export function useCollaborativeWorkflow() { const collaborativeUpdateVariable = useCallback( (variableId: string, field: 'name' | 'value' | 'type', value: any) => { - executeQueuedOperation('variable-update', 'variable', { variableId, field, value }, () => { - if (field === 'name') { - variablesStore.updateVariable(variableId, { name: value }) - } else if (field === 'value') { - variablesStore.updateVariable(variableId, { value }) - } else if (field === 'type') { - variablesStore.updateVariable(variableId, { type: value }) + executeQueuedOperation( + VARIABLE_OPERATIONS.UPDATE, + OPERATION_TARGETS.VARIABLE, + { variableId, field, value }, + () => { + if (field === 'name') { + variablesStore.updateVariable(variableId, { name: value }) + } else if (field === 'value') { + variablesStore.updateVariable(variableId, { value }) + } else if (field === 'type') { + variablesStore.updateVariable(variableId, { type: value }) + } } - }) + ) }, [executeQueuedOperation, variablesStore] ) @@ -1508,7 +1553,12 @@ export function useCollaborativeWorkflow() { // Queue operation with processed name for server & other clients // Empty callback because local store is already updated above - executeQueuedOperation('add', 'variable', payloadWithProcessedName, () => {}) + executeQueuedOperation( + VARIABLE_OPERATIONS.ADD, + OPERATION_TARGETS.VARIABLE, + payloadWithProcessedName, + () => {} + ) } return id @@ -1520,9 +1570,14 @@ export function useCollaborativeWorkflow() { (variableId: string) => { cancelOperationsForVariable(variableId) - executeQueuedOperation('remove', 'variable', { variableId }, () => { - variablesStore.deleteVariable(variableId) - }) + executeQueuedOperation( + VARIABLE_OPERATIONS.REMOVE, + OPERATION_TARGETS.VARIABLE, + { variableId }, + () => { + variablesStore.deleteVariable(variableId) + } + ) }, [executeQueuedOperation, variablesStore, cancelOperationsForVariable] ) @@ -1558,8 +1613,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'batch-add-blocks', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, payload: { blocks, edges, loops, parallels, subBlockValues }, }, workflowId: activeWorkflowId || '', @@ -1690,8 +1745,8 @@ export function useCollaborativeWorkflow() { addToQueue({ id: operationId, operation: { - operation: 'batch-remove-blocks', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, payload: { ids: Array.from(allBlocksToRemove) }, }, workflowId: activeWorkflowId || '', diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 304df71b6a..9eb05c7aa5 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -3,6 +3,14 @@ import { createLogger } from '@sim/logger' import type { Edge } from 'reactflow' import { useSession } from '@/lib/auth/auth-client' import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-operations' +import { + BLOCK_OPERATIONS, + BLOCKS_OPERATIONS, + EDGE_OPERATIONS, + EDGES_OPERATIONS, + OPERATION_TARGETS, + UNDO_REDO_OPERATIONS, +} from '@/socket/constants' import { useOperationQueue } from '@/stores/operation-queue/store' import { type BatchAddBlocksOperation, @@ -45,7 +53,7 @@ export function useUndoRedo() { const operation: BatchAddBlocksOperation = { id: crypto.randomUUID(), - type: 'batch-add-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -58,7 +66,7 @@ export function useUndoRedo() { const inverse: BatchRemoveBlocksOperation = { id: crypto.randomUUID(), - type: 'batch-remove-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -91,7 +99,7 @@ export function useUndoRedo() { const operation: BatchRemoveBlocksOperation = { id: crypto.randomUUID(), - type: 'batch-remove-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -104,7 +112,7 @@ export function useUndoRedo() { const inverse: BatchAddBlocksOperation = { id: crypto.randomUUID(), - type: 'batch-add-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -139,7 +147,7 @@ export function useUndoRedo() { const operation: BatchAddEdgesOperation = { id: crypto.randomUUID(), - type: 'batch-add-edges', + type: UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -148,7 +156,7 @@ export function useUndoRedo() { const inverse: BatchRemoveEdgesOperation = { id: crypto.randomUUID(), - type: 'batch-remove-edges', + type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -169,7 +177,7 @@ export function useUndoRedo() { const operation: BatchRemoveEdgesOperation = { id: crypto.randomUUID(), - type: 'batch-remove-edges', + type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -180,7 +188,7 @@ export function useUndoRedo() { const inverse: BatchAddEdgesOperation = { id: crypto.randomUUID(), - type: 'batch-add-edges', + type: UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -212,17 +220,16 @@ export function useUndoRedo() { const operation: BatchMoveBlocksOperation = { id: crypto.randomUUID(), - type: 'batch-move-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS, timestamp: Date.now(), workflowId: activeWorkflowId, userId, data: { moves }, } - // Inverse swaps before/after for each move const inverse: BatchMoveBlocksOperation = { id: crypto.randomUUID(), - type: 'batch-move-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -256,7 +263,7 @@ export function useUndoRedo() { const operation: UpdateParentOperation = { id: crypto.randomUUID(), - type: 'update-parent', + type: UNDO_REDO_OPERATIONS.UPDATE_PARENT, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -272,7 +279,7 @@ export function useUndoRedo() { const inverse: UpdateParentOperation = { id: crypto.randomUUID(), - type: 'update-parent', + type: UNDO_REDO_OPERATIONS.UPDATE_PARENT, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -282,7 +289,7 @@ export function useUndoRedo() { newParentId: oldParentId, oldPosition: newPosition, newPosition: oldPosition, - affectedEdges, // Same edges need to be restored + affectedEdges, }, } @@ -314,7 +321,7 @@ export function useUndoRedo() { const operation: BatchUpdateParentOperation = { id: crypto.randomUUID(), - type: 'batch-update-parent', + type: UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -323,7 +330,7 @@ export function useUndoRedo() { const inverse: BatchUpdateParentOperation = { id: crypto.randomUUID(), - type: 'batch-update-parent', + type: UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -356,7 +363,7 @@ export function useUndoRedo() { const operation: BatchToggleEnabledOperation = { id: crypto.randomUUID(), - type: 'batch-toggle-enabled', + type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -365,7 +372,7 @@ export function useUndoRedo() { const inverse: BatchToggleEnabledOperation = { id: crypto.randomUUID(), - type: 'batch-toggle-enabled', + type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -386,7 +393,7 @@ export function useUndoRedo() { const operation: BatchToggleHandlesOperation = { id: crypto.randomUUID(), - type: 'batch-toggle-handles', + type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -395,7 +402,7 @@ export function useUndoRedo() { const inverse: BatchToggleHandlesOperation = { id: crypto.randomUUID(), - type: 'batch-toggle-handles', + type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -429,7 +436,7 @@ export function useUndoRedo() { const opId = crypto.randomUUID() switch (entry.inverse.type) { - case 'batch-remove-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: { const batchRemoveOp = entry.inverse as BatchRemoveBlocksOperation const { blockSnapshots } = batchRemoveOp.data const blockIds = blockSnapshots.map((b) => b.id) @@ -466,8 +473,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-remove-blocks', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, payload: { ids: existingBlockIds }, }, workflowId: activeWorkflowId, @@ -477,7 +484,7 @@ export function useUndoRedo() { existingBlockIds.forEach((id) => workflowStore.removeBlock(id)) break } - case 'batch-add-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: { // Undoing a removal: inverse is batch-add-blocks, use entry.inverse for data const batchAddOp = entry.inverse as BatchAddBlocksOperation const { blockSnapshots, edgeSnapshots, subBlockValues } = batchAddOp.data @@ -491,8 +498,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-add-blocks', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, payload: { blocks: blocksToAdd, edges: edgeSnapshots || [], @@ -545,7 +552,7 @@ export function useUndoRedo() { } break } - case 'batch-remove-edges': { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: { // Undo batch-add-edges: inverse is batch-remove-edges, so remove the edges const batchRemoveInverse = entry.inverse as BatchRemoveEdgesOperation const { edgeSnapshots } = batchRemoveInverse.data @@ -558,8 +565,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-remove-edges', - target: 'edges', + operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + target: OPERATION_TARGETS.EDGES, payload: { ids: edgesToRemove }, }, workflowId: activeWorkflowId, @@ -570,7 +577,7 @@ export function useUndoRedo() { logger.debug('Undid batch-add-edges', { edgeCount: edgesToRemove.length }) break } - case 'batch-add-edges': { + case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: { // Undo batch-remove-edges: inverse is batch-add-edges, so add edges back const batchAddInverse = entry.inverse as BatchAddEdgesOperation const { edgeSnapshots } = batchAddInverse.data @@ -583,8 +590,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-add-edges', - target: 'edges', + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, payload: { edges: edgesToAdd }, }, workflowId: activeWorkflowId, @@ -595,7 +602,7 @@ export function useUndoRedo() { logger.debug('Undid batch-remove-edges', { edgeCount: edgesToAdd.length }) break } - case 'batch-move-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: { const batchMoveOp = entry.inverse as BatchMoveBlocksOperation const currentBlocks = useWorkflowStore.getState().blocks const positionUpdates: Array<{ id: string; position: { x: number; y: number } }> = [] @@ -617,8 +624,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-update-positions', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS, + target: OPERATION_TARGETS.BLOCKS, payload: { updates: positionUpdates }, }, workflowId: activeWorkflowId, @@ -627,7 +634,7 @@ export function useUndoRedo() { } break } - case 'update-parent': { + case UNDO_REDO_OPERATIONS.UPDATE_PARENT: { const updateOp = entry.inverse as UpdateParentOperation const { blockId, newParentId, newPosition, affectedEdges } = updateOp.data @@ -640,8 +647,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'batch-add-edges', - target: 'edges', + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, payload: { edges: edgesToAdd }, }, workflowId: activeWorkflowId, @@ -654,8 +661,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'update-position', - target: 'block', + operation: BLOCK_OPERATIONS.UPDATE_POSITION, + target: OPERATION_TARGETS.BLOCK, payload: { id: blockId, position: newPosition, @@ -675,8 +682,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'update-parent', - target: 'block', + operation: BLOCK_OPERATIONS.UPDATE_PARENT, + target: OPERATION_TARGETS.BLOCK, payload: { id: blockId, parentId: newParentId || '', @@ -700,8 +707,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'remove', - target: 'edge', + operation: EDGE_OPERATIONS.REMOVE, + target: OPERATION_TARGETS.EDGE, payload: { id: edge.id, isUndo: true }, }, workflowId: activeWorkflowId, @@ -715,7 +722,7 @@ export function useUndoRedo() { } break } - case 'batch-update-parent': { + case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: { const batchUpdateOp = entry.inverse as BatchUpdateParentOperation const { updates } = batchUpdateOp.data @@ -738,8 +745,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'batch-add-edges', - target: 'edges', + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, payload: { edges: edgesToAdd }, }, workflowId: activeWorkflowId, @@ -759,8 +766,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'batch-remove-edges', - target: 'edges', + operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + target: OPERATION_TARGETS.EDGES, payload: { edgeIds: affectedEdges.map((e) => e.id) }, }, workflowId: activeWorkflowId, @@ -777,8 +784,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-update-parent', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT, + target: OPERATION_TARGETS.BLOCKS, payload: { updates: validUpdates.map((u) => ({ id: u.blockId, @@ -794,7 +801,7 @@ export function useUndoRedo() { logger.debug('Undid batch-update-parent', { updateCount: validUpdates.length }) break } - case 'batch-toggle-enabled': { + case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED: { const toggleOp = entry.inverse as BatchToggleEnabledOperation const { blockIds, previousStates } = toggleOp.data @@ -807,8 +814,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-toggle-enabled', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, + target: OPERATION_TARGETS.BLOCKS, payload: { blockIds: validBlockIds, previousStates }, }, workflowId: activeWorkflowId, @@ -822,7 +829,7 @@ export function useUndoRedo() { }) break } - case 'batch-toggle-handles': { + case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES: { const toggleOp = entry.inverse as BatchToggleHandlesOperation const { blockIds, previousStates } = toggleOp.data @@ -835,8 +842,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-toggle-handles', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES, + target: OPERATION_TARGETS.BLOCKS, payload: { blockIds: validBlockIds, previousStates }, }, workflowId: activeWorkflowId, @@ -850,7 +857,7 @@ export function useUndoRedo() { }) break } - case 'apply-diff': { + case UNDO_REDO_OPERATIONS.APPLY_DIFF: { const applyDiffInverse = entry.inverse as any const { baselineSnapshot } = applyDiffInverse.data @@ -909,7 +916,7 @@ export function useUndoRedo() { logger.info('Undid apply-diff operation successfully') break } - case 'accept-diff': { + case UNDO_REDO_OPERATIONS.ACCEPT_DIFF: { // Undo accept-diff means restoring diff view with markers const acceptDiffInverse = entry.inverse as any const acceptDiffOp = entry.operation as any @@ -968,7 +975,7 @@ export function useUndoRedo() { logger.info('Undid accept-diff operation - restored diff view') break } - case 'reject-diff': { + case UNDO_REDO_OPERATIONS.REJECT_DIFF: { // Undo reject-diff means restoring diff view with markers const rejectDiffInverse = entry.inverse as any const { beforeReject, diffAnalysis, baselineSnapshot } = rejectDiffInverse.data @@ -1038,7 +1045,7 @@ export function useUndoRedo() { const opId = crypto.randomUUID() switch (entry.operation.type) { - case 'batch-add-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: { const batchOp = entry.operation as BatchAddBlocksOperation const { blockSnapshots, edgeSnapshots, subBlockValues } = batchOp.data @@ -1051,8 +1058,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-add-blocks', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, payload: { blocks: blocksToAdd, edges: edgeSnapshots || [], @@ -1105,7 +1112,7 @@ export function useUndoRedo() { } break } - case 'batch-remove-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: { const batchOp = entry.operation as BatchRemoveBlocksOperation const { blockSnapshots } = batchOp.data const blockIds = blockSnapshots.map((b) => b.id) @@ -1119,8 +1126,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-remove-blocks', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS, + target: OPERATION_TARGETS.BLOCKS, payload: { ids: existingBlockIds }, }, workflowId: activeWorkflowId, @@ -1130,7 +1137,7 @@ export function useUndoRedo() { existingBlockIds.forEach((id) => workflowStore.removeBlock(id)) break } - case 'batch-remove-edges': { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: { // Redo batch-remove-edges: remove all edges again const batchRemoveOp = entry.operation as BatchRemoveEdgesOperation const { edgeSnapshots } = batchRemoveOp.data @@ -1143,8 +1150,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-remove-edges', - target: 'edges', + operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + target: OPERATION_TARGETS.EDGES, payload: { ids: edgesToRemove }, }, workflowId: activeWorkflowId, @@ -1156,7 +1163,7 @@ export function useUndoRedo() { logger.debug('Redid batch-remove-edges', { edgeCount: edgesToRemove.length }) break } - case 'batch-add-edges': { + case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: { // Redo batch-add-edges: add all edges again const batchAddOp = entry.operation as BatchAddEdgesOperation const { edgeSnapshots } = batchAddOp.data @@ -1169,8 +1176,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-add-edges', - target: 'edges', + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, payload: { edges: edgesToAdd }, }, workflowId: activeWorkflowId, @@ -1182,7 +1189,7 @@ export function useUndoRedo() { logger.debug('Redid batch-add-edges', { edgeCount: edgesToAdd.length }) break } - case 'batch-move-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: { const batchMoveOp = entry.operation as BatchMoveBlocksOperation const currentBlocks = useWorkflowStore.getState().blocks const positionUpdates: Array<{ id: string; position: { x: number; y: number } }> = [] @@ -1204,8 +1211,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-update-positions', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS, + target: OPERATION_TARGETS.BLOCKS, payload: { updates: positionUpdates }, }, workflowId: activeWorkflowId, @@ -1214,7 +1221,7 @@ export function useUndoRedo() { } break } - case 'update-parent': { + case UNDO_REDO_OPERATIONS.UPDATE_PARENT: { // Redo parent update means applying the new parent and position const updateOp = entry.operation as UpdateParentOperation const { blockId, newParentId, newPosition, affectedEdges } = updateOp.data @@ -1228,8 +1235,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'remove', - target: 'edge', + operation: EDGE_OPERATIONS.REMOVE, + target: OPERATION_TARGETS.EDGE, payload: { id: edge.id, isRedo: true }, }, workflowId: activeWorkflowId, @@ -1243,8 +1250,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'update-position', - target: 'block', + operation: BLOCK_OPERATIONS.UPDATE_POSITION, + target: OPERATION_TARGETS.BLOCK, payload: { id: blockId, position: newPosition, @@ -1264,8 +1271,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'update-parent', - target: 'block', + operation: BLOCK_OPERATIONS.UPDATE_PARENT, + target: OPERATION_TARGETS.BLOCK, payload: { id: blockId, parentId: newParentId || '', @@ -1290,8 +1297,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'batch-add-edges', - target: 'edges', + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, payload: { edges: edgesToAdd }, }, workflowId: activeWorkflowId, @@ -1305,7 +1312,7 @@ export function useUndoRedo() { } break } - case 'batch-update-parent': { + case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: { const batchUpdateOp = entry.operation as BatchUpdateParentOperation const { updates } = batchUpdateOp.data diff --git a/apps/sim/socket/constants.ts b/apps/sim/socket/constants.ts new file mode 100644 index 0000000000..89aa7020e6 --- /dev/null +++ b/apps/sim/socket/constants.ts @@ -0,0 +1,96 @@ +export const BLOCK_OPERATIONS = { + UPDATE_POSITION: 'update-position', + UPDATE_NAME: 'update-name', + TOGGLE_ENABLED: 'toggle-enabled', + UPDATE_PARENT: 'update-parent', + UPDATE_WIDE: 'update-wide', + UPDATE_ADVANCED_MODE: 'update-advanced-mode', + UPDATE_TRIGGER_MODE: 'update-trigger-mode', + TOGGLE_HANDLES: 'toggle-handles', +} as const + +export type BlockOperation = (typeof BLOCK_OPERATIONS)[keyof typeof BLOCK_OPERATIONS] + +export const BLOCKS_OPERATIONS = { + BATCH_UPDATE_POSITIONS: 'batch-update-positions', + BATCH_ADD_BLOCKS: 'batch-add-blocks', + BATCH_REMOVE_BLOCKS: 'batch-remove-blocks', + BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled', + BATCH_TOGGLE_HANDLES: 'batch-toggle-handles', + BATCH_UPDATE_PARENT: 'batch-update-parent', +} as const + +export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS] + +export const EDGE_OPERATIONS = { + ADD: 'add', + REMOVE: 'remove', +} as const + +export type EdgeOperation = (typeof EDGE_OPERATIONS)[keyof typeof EDGE_OPERATIONS] + +export const EDGES_OPERATIONS = { + BATCH_ADD_EDGES: 'batch-add-edges', + BATCH_REMOVE_EDGES: 'batch-remove-edges', +} as const + +export type EdgesOperation = (typeof EDGES_OPERATIONS)[keyof typeof EDGES_OPERATIONS] + +export const SUBFLOW_OPERATIONS = { + ADD: 'add', + REMOVE: 'remove', + UPDATE: 'update', +} as const + +export type SubflowOperation = (typeof SUBFLOW_OPERATIONS)[keyof typeof SUBFLOW_OPERATIONS] + +export const VARIABLE_OPERATIONS = { + ADD: 'add', + REMOVE: 'remove', + UPDATE: 'variable-update', +} as const + +export type VariableOperation = (typeof VARIABLE_OPERATIONS)[keyof typeof VARIABLE_OPERATIONS] + +export const WORKFLOW_OPERATIONS = { + REPLACE_STATE: 'replace-state', +} as const + +export type WorkflowOperation = (typeof WORKFLOW_OPERATIONS)[keyof typeof WORKFLOW_OPERATIONS] + +export const SUBBLOCK_OPERATIONS = { + UPDATE: 'subblock-update', +} as const + +export type SubblockOperation = (typeof SUBBLOCK_OPERATIONS)[keyof typeof SUBBLOCK_OPERATIONS] + +export const OPERATION_TARGETS = { + BLOCK: 'block', + BLOCKS: 'blocks', + EDGE: 'edge', + EDGES: 'edges', + SUBBLOCK: 'subblock', + SUBFLOW: 'subflow', + VARIABLE: 'variable', + WORKFLOW: 'workflow', +} as const + +export type OperationTarget = (typeof OPERATION_TARGETS)[keyof typeof OPERATION_TARGETS] + +/** Undo/Redo operation types (includes some socket operations + undo-specific ones) */ +export const UNDO_REDO_OPERATIONS = { + BATCH_ADD_BLOCKS: 'batch-add-blocks', + BATCH_REMOVE_BLOCKS: 'batch-remove-blocks', + BATCH_ADD_EDGES: 'batch-add-edges', + BATCH_REMOVE_EDGES: 'batch-remove-edges', + BATCH_MOVE_BLOCKS: 'batch-move-blocks', + UPDATE_PARENT: 'update-parent', + BATCH_UPDATE_PARENT: 'batch-update-parent', + BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled', + BATCH_TOGGLE_HANDLES: 'batch-toggle-handles', + APPLY_DIFF: 'apply-diff', + ACCEPT_DIFF: 'accept-diff', + REJECT_DIFF: 'reject-diff', +} as const + +export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS] diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index 802674ae75..59e73ef605 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -7,6 +7,16 @@ import postgres from 'postgres' import { env } from '@/lib/core/config/env' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { + BLOCK_OPERATIONS, + BLOCKS_OPERATIONS, + EDGE_OPERATIONS, + EDGES_OPERATIONS, + OPERATION_TARGETS, + SUBFLOW_OPERATIONS, + VARIABLE_OPERATIONS, + WORKFLOW_OPERATIONS, +} from '@/socket/constants' const logger = createLogger('SocketDatabase') @@ -155,7 +165,7 @@ export async function persistWorkflowOperation(workflowId: string, operation: an try { const { operation: op, target, payload, timestamp, userId } = operation - if (op === 'update-position' && Math.random() < 0.01) { + if (op === BLOCK_OPERATIONS.UPDATE_POSITION && Math.random() < 0.01) { logger.debug('Socket DB operation sample:', { operation: op, target, @@ -170,25 +180,25 @@ export async function persistWorkflowOperation(workflowId: string, operation: an .where(eq(workflow.id, workflowId)) switch (target) { - case 'block': + case OPERATION_TARGETS.BLOCK: await handleBlockOperationTx(tx, workflowId, op, payload) break - case 'blocks': + case OPERATION_TARGETS.BLOCKS: await handleBlocksOperationTx(tx, workflowId, op, payload) break - case 'edge': + case OPERATION_TARGETS.EDGE: await handleEdgeOperationTx(tx, workflowId, op, payload) break - case 'edges': + case OPERATION_TARGETS.EDGES: await handleEdgesOperationTx(tx, workflowId, op, payload) break - case 'subflow': + case OPERATION_TARGETS.SUBFLOW: await handleSubflowOperationTx(tx, workflowId, op, payload) break - case 'variable': + case OPERATION_TARGETS.VARIABLE: await handleVariableOperationTx(tx, workflowId, op, payload) break - case 'workflow': + case OPERATION_TARGETS.WORKFLOW: await handleWorkflowOperationTx(tx, workflowId, op, payload) break default: @@ -222,7 +232,7 @@ async function handleBlockOperationTx( payload: any ) { switch (operation) { - case 'update-position': { + case BLOCK_OPERATIONS.UPDATE_POSITION: { if (!payload.id || !payload.position) { throw new Error('Missing required fields for update position operation') } @@ -247,7 +257,7 @@ async function handleBlockOperationTx( break } - case 'update-name': { + case BLOCK_OPERATIONS.UPDATE_NAME: { if (!payload.id || !payload.name) { throw new Error('Missing required fields for update name operation') } @@ -269,7 +279,7 @@ async function handleBlockOperationTx( break } - case 'toggle-enabled': { + case BLOCK_OPERATIONS.TOGGLE_ENABLED: { if (!payload.id) { throw new Error('Missing block ID for toggle enabled operation') } @@ -299,7 +309,7 @@ async function handleBlockOperationTx( break } - case 'update-parent': { + case BLOCK_OPERATIONS.UPDATE_PARENT: { if (!payload.id) { throw new Error('Missing block ID for update parent operation') } @@ -364,7 +374,7 @@ async function handleBlockOperationTx( break } - case 'update-advanced-mode': { + case BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE: { if (!payload.id || payload.advancedMode === undefined) { throw new Error('Missing required fields for update advanced mode operation') } @@ -386,7 +396,7 @@ async function handleBlockOperationTx( break } - case 'update-trigger-mode': { + case BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE: { if (!payload.id || payload.triggerMode === undefined) { throw new Error('Missing required fields for update trigger mode operation') } @@ -408,7 +418,7 @@ async function handleBlockOperationTx( break } - case 'toggle-handles': { + case BLOCK_OPERATIONS.TOGGLE_HANDLES: { if (!payload.id || payload.horizontalHandles === undefined) { throw new Error('Missing required fields for toggle handles operation') } @@ -445,7 +455,7 @@ async function handleBlocksOperationTx( payload: any ) { switch (operation) { - case 'batch-update-positions': { + case BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS: { const { updates } = payload if (!Array.isArray(updates) || updates.length === 0) { return @@ -466,7 +476,7 @@ async function handleBlocksOperationTx( break } - case 'batch-add-blocks': { + case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: { const { blocks, edges, loops, parallels } = payload logger.info(`Batch adding blocks to workflow ${workflowId}`, { @@ -578,7 +588,7 @@ async function handleBlocksOperationTx( break } - case 'batch-remove-blocks': { + case BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS: { const { ids } = payload if (!Array.isArray(ids) || ids.length === 0) { return @@ -693,7 +703,7 @@ async function handleBlocksOperationTx( break } - case 'batch-toggle-enabled': { + case BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED: { const { blockIds } = payload if (!Array.isArray(blockIds) || blockIds.length === 0) { return @@ -722,7 +732,7 @@ async function handleBlocksOperationTx( break } - case 'batch-toggle-handles': { + case BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES: { const { blockIds } = payload if (!Array.isArray(blockIds) || blockIds.length === 0) { return @@ -749,7 +759,7 @@ async function handleBlocksOperationTx( break } - case 'batch-update-parent': { + case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: { const { updates } = payload if (!Array.isArray(updates) || updates.length === 0) { return @@ -829,7 +839,7 @@ async function handleBlocksOperationTx( async function handleEdgeOperationTx(tx: any, workflowId: string, operation: string, payload: any) { switch (operation) { - case 'add': { + case EDGE_OPERATIONS.ADD: { // Validate required fields if (!payload.id || !payload.source || !payload.target) { throw new Error('Missing required fields for add edge operation') @@ -848,7 +858,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str break } - case 'remove': { + case EDGE_OPERATIONS.REMOVE: { if (!payload.id) { throw new Error('Missing edge ID for remove operation') } @@ -879,7 +889,7 @@ async function handleEdgesOperationTx( payload: any ) { switch (operation) { - case 'batch-remove-edges': { + case EDGES_OPERATIONS.BATCH_REMOVE_EDGES: { const { ids } = payload if (!Array.isArray(ids) || ids.length === 0) { logger.debug('No edge IDs provided for batch remove') @@ -896,7 +906,7 @@ async function handleEdgesOperationTx( break } - case 'batch-add-edges': { + case EDGES_OPERATIONS.BATCH_ADD_EDGES: { const { edges } = payload if (!Array.isArray(edges) || edges.length === 0) { logger.debug('No edges provided for batch add') @@ -933,7 +943,7 @@ async function handleSubflowOperationTx( payload: any ) { switch (operation) { - case 'update': { + case SUBFLOW_OPERATIONS.UPDATE: { if (!payload.id || !payload.config) { throw new Error('Missing required fields for update subflow operation') } @@ -1060,7 +1070,7 @@ async function handleVariableOperationTx( const currentVariables = (workflowData[0].variables as Record) || {} switch (operation) { - case 'add': { + case VARIABLE_OPERATIONS.ADD: { if (!payload.id || !payload.name || payload.type === undefined) { throw new Error('Missing required fields for add variable operation') } @@ -1089,7 +1099,7 @@ async function handleVariableOperationTx( break } - case 'remove': { + case VARIABLE_OPERATIONS.REMOVE: { if (!payload.variableId) { throw new Error('Missing variable ID for remove operation') } @@ -1123,7 +1133,7 @@ async function handleWorkflowOperationTx( payload: any ) { switch (operation) { - case 'replace-state': { + case WORKFLOW_OPERATIONS.REPLACE_STATE: { if (!payload.state) { throw new Error('Missing state for replace-state operation') } diff --git a/apps/sim/socket/handlers/operations.ts b/apps/sim/socket/handlers/operations.ts index 28d563b99d..0d6b2f349b 100644 --- a/apps/sim/socket/handlers/operations.ts +++ b/apps/sim/socket/handlers/operations.ts @@ -1,5 +1,13 @@ import { createLogger } from '@sim/logger' import { ZodError } from 'zod' +import { + BLOCK_OPERATIONS, + BLOCKS_OPERATIONS, + EDGES_OPERATIONS, + OPERATION_TARGETS, + VARIABLE_OPERATIONS, + WORKFLOW_OPERATIONS, +} from '@/socket/constants' import { persistWorkflowOperation } from '@/socket/database/operations' import type { HandlerDependencies } from '@/socket/handlers/workflow' import type { AuthenticatedSocket } from '@/socket/middleware/auth' @@ -45,7 +53,8 @@ export function setupOperationsHandlers( // For position updates, preserve client timestamp to maintain ordering // For other operations, use server timestamp for consistency - const isPositionUpdate = operation === 'update-position' && target === 'block' + const isPositionUpdate = + operation === BLOCK_OPERATIONS.UPDATE_POSITION && target === OPERATION_TARGETS.BLOCK const commitPositionUpdate = isPositionUpdate && 'commit' in payload ? payload.commit === true : false const operationTimestamp = isPositionUpdate ? timestamp : Date.now() @@ -145,7 +154,10 @@ export function setupOperationsHandlers( return } - if (target === 'blocks' && operation === 'batch-update-positions') { + if ( + target === OPERATION_TARGETS.BLOCKS && + operation === BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS + ) { socket.to(workflowId).emit('workflow-operation', { operation, target, @@ -184,8 +196,10 @@ export function setupOperationsHandlers( return } - if (target === 'variable' && ['add', 'remove'].includes(operation)) { - // Persist first, then broadcast + if ( + target === OPERATION_TARGETS.VARIABLE && + [VARIABLE_OPERATIONS.ADD, VARIABLE_OPERATIONS.REMOVE].includes(operation as any) + ) { await persistWorkflowOperation(workflowId, { operation, target, @@ -222,7 +236,10 @@ export function setupOperationsHandlers( return } - if (target === 'workflow' && operation === 'replace-state') { + if ( + target === OPERATION_TARGETS.WORKFLOW && + operation === WORKFLOW_OPERATIONS.REPLACE_STATE + ) { await persistWorkflowOperation(workflowId, { operation, target, @@ -259,7 +276,7 @@ export function setupOperationsHandlers( return } - if (target === 'blocks' && operation === 'batch-add-blocks') { + if (target === OPERATION_TARGETS.BLOCKS && operation === BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS) { await persistWorkflowOperation(workflowId, { operation, target, @@ -288,7 +305,10 @@ export function setupOperationsHandlers( return } - if (target === 'blocks' && operation === 'batch-remove-blocks') { + if ( + target === OPERATION_TARGETS.BLOCKS && + operation === BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS + ) { await persistWorkflowOperation(workflowId, { operation, target, @@ -317,7 +337,7 @@ export function setupOperationsHandlers( return } - if (target === 'edges' && operation === 'batch-remove-edges') { + if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_REMOVE_EDGES) { await persistWorkflowOperation(workflowId, { operation, target, @@ -346,7 +366,10 @@ export function setupOperationsHandlers( return } - if (target === 'blocks' && operation === 'batch-toggle-enabled') { + if ( + target === OPERATION_TARGETS.BLOCKS && + operation === BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED + ) { await persistWorkflowOperation(workflowId, { operation, target, @@ -375,7 +398,10 @@ export function setupOperationsHandlers( return } - if (target === 'blocks' && operation === 'batch-toggle-handles') { + if ( + target === OPERATION_TARGETS.BLOCKS && + operation === BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES + ) { await persistWorkflowOperation(workflowId, { operation, target, @@ -404,7 +430,10 @@ export function setupOperationsHandlers( return } - if (target === 'blocks' && operation === 'batch-update-parent') { + if ( + target === OPERATION_TARGETS.BLOCKS && + operation === BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT + ) { await persistWorkflowOperation(workflowId, { operation, target, @@ -433,7 +462,7 @@ export function setupOperationsHandlers( return } - if (target === 'edges' && operation === 'batch-add-edges') { + if (target === OPERATION_TARGETS.EDGES && operation === EDGES_OPERATIONS.BATCH_ADD_EDGES) { await persistWorkflowOperation(workflowId, { operation, target, diff --git a/apps/sim/socket/validation/schemas.ts b/apps/sim/socket/validation/schemas.ts index 13266f7808..85499b0c5b 100644 --- a/apps/sim/socket/validation/schemas.ts +++ b/apps/sim/socket/validation/schemas.ts @@ -1,4 +1,14 @@ import { z } from 'zod' +import { + BLOCK_OPERATIONS, + BLOCKS_OPERATIONS, + EDGE_OPERATIONS, + EDGES_OPERATIONS, + OPERATION_TARGETS, + SUBFLOW_OPERATIONS, + VARIABLE_OPERATIONS, + WORKFLOW_OPERATIONS, +} from '@/socket/constants' const PositionSchema = z.object({ x: z.number(), @@ -16,16 +26,16 @@ const AutoConnectEdgeSchema = z.object({ export const BlockOperationSchema = z.object({ operation: z.enum([ - 'update-position', - 'update-name', - 'toggle-enabled', - 'update-parent', - 'update-wide', - 'update-advanced-mode', - 'update-trigger-mode', - 'toggle-handles', + BLOCK_OPERATIONS.UPDATE_POSITION, + BLOCK_OPERATIONS.UPDATE_NAME, + BLOCK_OPERATIONS.TOGGLE_ENABLED, + BLOCK_OPERATIONS.UPDATE_PARENT, + BLOCK_OPERATIONS.UPDATE_WIDE, + BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, + BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE, + BLOCK_OPERATIONS.TOGGLE_HANDLES, ]), - target: z.literal('block'), + target: z.literal(OPERATION_TARGETS.BLOCK), payload: z.object({ id: z.string(), type: z.string().optional(), @@ -48,8 +58,8 @@ export const BlockOperationSchema = z.object({ }) export const BatchPositionUpdateSchema = z.object({ - operation: z.literal('batch-update-positions'), - target: z.literal('blocks'), + operation: z.literal(BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS), + target: z.literal(OPERATION_TARGETS.BLOCKS), payload: z.object({ updates: z.array( z.object({ @@ -63,8 +73,8 @@ export const BatchPositionUpdateSchema = z.object({ }) export const EdgeOperationSchema = z.object({ - operation: z.enum(['add', 'remove']), - target: z.literal('edge'), + operation: z.enum([EDGE_OPERATIONS.ADD, EDGE_OPERATIONS.REMOVE]), + target: z.literal(OPERATION_TARGETS.EDGE), payload: z.object({ id: z.string(), source: z.string().optional(), @@ -77,8 +87,8 @@ export const EdgeOperationSchema = z.object({ }) export const SubflowOperationSchema = z.object({ - operation: z.enum(['add', 'remove', 'update']), - target: z.literal('subflow'), + operation: z.enum([SUBFLOW_OPERATIONS.ADD, SUBFLOW_OPERATIONS.REMOVE, SUBFLOW_OPERATIONS.UPDATE]), + target: z.literal(OPERATION_TARGETS.SUBFLOW), payload: z.object({ id: z.string(), type: z.enum(['loop', 'parallel']).optional(), @@ -90,8 +100,8 @@ export const SubflowOperationSchema = z.object({ export const VariableOperationSchema = z.union([ z.object({ - operation: z.literal('add'), - target: z.literal('variable'), + operation: z.literal(VARIABLE_OPERATIONS.ADD), + target: z.literal(OPERATION_TARGETS.VARIABLE), payload: z.object({ id: z.string(), name: z.string(), @@ -103,8 +113,8 @@ export const VariableOperationSchema = z.union([ operationId: z.string().optional(), }), z.object({ - operation: z.literal('remove'), - target: z.literal('variable'), + operation: z.literal(VARIABLE_OPERATIONS.REMOVE), + target: z.literal(OPERATION_TARGETS.VARIABLE), payload: z.object({ variableId: z.string(), }), @@ -114,8 +124,8 @@ export const VariableOperationSchema = z.union([ ]) export const WorkflowStateOperationSchema = z.object({ - operation: z.literal('replace-state'), - target: z.literal('workflow'), + operation: z.literal(WORKFLOW_OPERATIONS.REPLACE_STATE), + target: z.literal(OPERATION_TARGETS.WORKFLOW), payload: z.object({ state: z.any(), }), @@ -124,8 +134,8 @@ export const WorkflowStateOperationSchema = z.object({ }) export const BatchAddBlocksSchema = z.object({ - operation: z.literal('batch-add-blocks'), - target: z.literal('blocks'), + operation: z.literal(BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS), + target: z.literal(OPERATION_TARGETS.BLOCKS), payload: z.object({ blocks: z.array(z.record(z.any())), edges: z.array(AutoConnectEdgeSchema).optional(), @@ -138,8 +148,8 @@ export const BatchAddBlocksSchema = z.object({ }) export const BatchRemoveBlocksSchema = z.object({ - operation: z.literal('batch-remove-blocks'), - target: z.literal('blocks'), + operation: z.literal(BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS), + target: z.literal(OPERATION_TARGETS.BLOCKS), payload: z.object({ ids: z.array(z.string()), }), @@ -148,8 +158,8 @@ export const BatchRemoveBlocksSchema = z.object({ }) export const BatchRemoveEdgesSchema = z.object({ - operation: z.literal('batch-remove-edges'), - target: z.literal('edges'), + operation: z.literal(EDGES_OPERATIONS.BATCH_REMOVE_EDGES), + target: z.literal(OPERATION_TARGETS.EDGES), payload: z.object({ ids: z.array(z.string()), }), @@ -158,8 +168,8 @@ export const BatchRemoveEdgesSchema = z.object({ }) export const BatchAddEdgesSchema = z.object({ - operation: z.literal('batch-add-edges'), - target: z.literal('edges'), + operation: z.literal(EDGES_OPERATIONS.BATCH_ADD_EDGES), + target: z.literal(OPERATION_TARGETS.EDGES), payload: z.object({ edges: z.array( z.object({ @@ -176,8 +186,8 @@ export const BatchAddEdgesSchema = z.object({ }) export const BatchToggleEnabledSchema = z.object({ - operation: z.literal('batch-toggle-enabled'), - target: z.literal('blocks'), + operation: z.literal(BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED), + target: z.literal(OPERATION_TARGETS.BLOCKS), payload: z.object({ blockIds: z.array(z.string()), previousStates: z.record(z.boolean()), @@ -187,8 +197,8 @@ export const BatchToggleEnabledSchema = z.object({ }) export const BatchToggleHandlesSchema = z.object({ - operation: z.literal('batch-toggle-handles'), - target: z.literal('blocks'), + operation: z.literal(BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES), + target: z.literal(OPERATION_TARGETS.BLOCKS), payload: z.object({ blockIds: z.array(z.string()), previousStates: z.record(z.boolean()), @@ -198,8 +208,8 @@ export const BatchToggleHandlesSchema = z.object({ }) export const BatchUpdateParentSchema = z.object({ - operation: z.literal('batch-update-parent'), - target: z.literal('blocks'), + operation: z.literal(BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT), + target: z.literal(OPERATION_TARGETS.BLOCKS), payload: z.object({ updates: z.array( z.object({ diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts index 881e26ee80..6776bf66e1 100644 --- a/apps/sim/stores/undo-redo/store.ts +++ b/apps/sim/stores/undo-redo/store.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import type { Edge } from 'reactflow' import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' +import { UNDO_REDO_OPERATIONS } from '@/socket/constants' import type { BatchAddBlocksOperation, BatchAddEdgesOperation, @@ -86,31 +87,31 @@ function isOperationApplicable( graph: { blocksById: Record; edgesById: Record } ): boolean { switch (operation.type) { - case 'batch-remove-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: { const op = operation as BatchRemoveBlocksOperation return op.data.blockSnapshots.every((block) => Boolean(graph.blocksById[block.id])) } - case 'batch-add-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: { const op = operation as BatchAddBlocksOperation return op.data.blockSnapshots.every((block) => !graph.blocksById[block.id]) } - case 'batch-move-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: { const op = operation as BatchMoveBlocksOperation return op.data.moves.every((move) => Boolean(graph.blocksById[move.blockId])) } - case 'update-parent': { + case UNDO_REDO_OPERATIONS.UPDATE_PARENT: { const blockId = operation.data.blockId return Boolean(graph.blocksById[blockId]) } - case 'batch-update-parent': { + case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: { const op = operation as BatchUpdateParentOperation return op.data.updates.every((u) => Boolean(graph.blocksById[u.blockId])) } - case 'batch-remove-edges': { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: { const op = operation as BatchRemoveEdgesOperation return op.data.edgeSnapshots.every((edge) => Boolean(graph.edgesById[edge.id])) } - case 'batch-add-edges': { + case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: { const op = operation as BatchAddEdgesOperation return op.data.edgeSnapshots.every((edge) => !graph.edgesById[edge.id]) } diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index 53147d7e7c..f68aa66e68 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -1,19 +1,8 @@ import type { Edge } from 'reactflow' +import type { UNDO_REDO_OPERATIONS, UndoRedoOperation } from '@/socket/constants' import type { BlockState } from '@/stores/workflows/workflow/types' -export type OperationType = - | 'batch-add-blocks' - | 'batch-remove-blocks' - | 'batch-add-edges' - | 'batch-remove-edges' - | 'batch-move-blocks' - | 'update-parent' - | 'batch-update-parent' - | 'batch-toggle-enabled' - | 'batch-toggle-handles' - | 'apply-diff' - | 'accept-diff' - | 'reject-diff' +export type OperationType = UndoRedoOperation export interface BaseOperation { id: string @@ -24,7 +13,7 @@ export interface BaseOperation { } export interface BatchAddBlocksOperation extends BaseOperation { - type: 'batch-add-blocks' + type: typeof UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS data: { blockSnapshots: BlockState[] edgeSnapshots: Edge[] @@ -33,7 +22,7 @@ export interface BatchAddBlocksOperation extends BaseOperation { } export interface BatchRemoveBlocksOperation extends BaseOperation { - type: 'batch-remove-blocks' + type: typeof UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS data: { blockSnapshots: BlockState[] edgeSnapshots: Edge[] @@ -42,21 +31,21 @@ export interface BatchRemoveBlocksOperation extends BaseOperation { } export interface BatchAddEdgesOperation extends BaseOperation { - type: 'batch-add-edges' + type: typeof UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES data: { edgeSnapshots: Edge[] } } export interface BatchRemoveEdgesOperation extends BaseOperation { - type: 'batch-remove-edges' + type: typeof UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES data: { edgeSnapshots: Edge[] } } export interface BatchMoveBlocksOperation extends BaseOperation { - type: 'batch-move-blocks' + type: typeof UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS data: { moves: Array<{ blockId: string @@ -67,7 +56,7 @@ export interface BatchMoveBlocksOperation extends BaseOperation { } export interface UpdateParentOperation extends BaseOperation { - type: 'update-parent' + type: typeof UNDO_REDO_OPERATIONS.UPDATE_PARENT data: { blockId: string oldParentId?: string @@ -79,7 +68,7 @@ export interface UpdateParentOperation extends BaseOperation { } export interface BatchUpdateParentOperation extends BaseOperation { - type: 'batch-update-parent' + type: typeof UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT data: { updates: Array<{ blockId: string @@ -93,7 +82,7 @@ export interface BatchUpdateParentOperation extends BaseOperation { } export interface BatchToggleEnabledOperation extends BaseOperation { - type: 'batch-toggle-enabled' + type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED data: { blockIds: string[] previousStates: Record @@ -101,7 +90,7 @@ export interface BatchToggleEnabledOperation extends BaseOperation { } export interface BatchToggleHandlesOperation extends BaseOperation { - type: 'batch-toggle-handles' + type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES data: { blockIds: string[] previousStates: Record @@ -109,7 +98,7 @@ export interface BatchToggleHandlesOperation extends BaseOperation { } export interface ApplyDiffOperation extends BaseOperation { - type: 'apply-diff' + type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF data: { baselineSnapshot: any // WorkflowState snapshot before diff proposedState: any // WorkflowState with diff applied @@ -118,7 +107,7 @@ export interface ApplyDiffOperation extends BaseOperation { } export interface AcceptDiffOperation extends BaseOperation { - type: 'accept-diff' + type: typeof UNDO_REDO_OPERATIONS.ACCEPT_DIFF data: { beforeAccept: any // WorkflowState with diff markers afterAccept: any // WorkflowState without diff markers @@ -128,7 +117,7 @@ export interface AcceptDiffOperation extends BaseOperation { } export interface RejectDiffOperation extends BaseOperation { - type: 'reject-diff' + type: typeof UNDO_REDO_OPERATIONS.REJECT_DIFF data: { beforeReject: any // WorkflowState with diff markers afterReject: any // WorkflowState baseline (after reject) diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index d56604be2d..e747c2fd2d 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -1,3 +1,4 @@ +import { UNDO_REDO_OPERATIONS } from '@/socket/constants' import type { BatchAddBlocksOperation, BatchAddEdgesOperation, @@ -20,11 +21,11 @@ export function createOperationEntry(operation: Operation, inverse: Operation): export function createInverseOperation(operation: Operation): Operation { switch (operation.type) { - case 'batch-add-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: { const op = operation as BatchAddBlocksOperation return { ...operation, - type: 'batch-remove-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS, data: { blockSnapshots: op.data.blockSnapshots, edgeSnapshots: op.data.edgeSnapshots, @@ -33,11 +34,11 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchRemoveBlocksOperation } - case 'batch-remove-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: { const op = operation as BatchRemoveBlocksOperation return { ...operation, - type: 'batch-add-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS, data: { blockSnapshots: op.data.blockSnapshots, edgeSnapshots: op.data.edgeSnapshots, @@ -46,33 +47,33 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchAddBlocksOperation } - case 'batch-add-edges': { + case UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES: { const op = operation as BatchAddEdgesOperation return { ...operation, - type: 'batch-remove-edges', + type: UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES, data: { edgeSnapshots: op.data.edgeSnapshots, }, } as BatchRemoveEdgesOperation } - case 'batch-remove-edges': { + case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: { const op = operation as BatchRemoveEdgesOperation return { ...operation, - type: 'batch-add-edges', + type: UNDO_REDO_OPERATIONS.BATCH_ADD_EDGES, data: { edgeSnapshots: op.data.edgeSnapshots, }, } as BatchAddEdgesOperation } - case 'batch-move-blocks': { + case UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS: { const op = operation as BatchMoveBlocksOperation return { ...operation, - type: 'batch-move-blocks', + type: UNDO_REDO_OPERATIONS.BATCH_MOVE_BLOCKS, data: { moves: op.data.moves.map((m) => ({ blockId: m.blockId, @@ -83,7 +84,7 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchMoveBlocksOperation } - case 'update-parent': + case UNDO_REDO_OPERATIONS.UPDATE_PARENT: return { ...operation, data: { @@ -96,7 +97,7 @@ export function createInverseOperation(operation: Operation): Operation { }, } - case 'batch-update-parent': { + case UNDO_REDO_OPERATIONS.BATCH_UPDATE_PARENT: { const op = operation as BatchUpdateParentOperation return { ...operation, @@ -113,7 +114,7 @@ export function createInverseOperation(operation: Operation): Operation { } as BatchUpdateParentOperation } - case 'apply-diff': + case UNDO_REDO_OPERATIONS.APPLY_DIFF: return { ...operation, data: { @@ -123,7 +124,7 @@ export function createInverseOperation(operation: Operation): Operation { }, } - case 'accept-diff': + case UNDO_REDO_OPERATIONS.ACCEPT_DIFF: return { ...operation, data: { @@ -134,7 +135,7 @@ export function createInverseOperation(operation: Operation): Operation { }, } - case 'reject-diff': + case UNDO_REDO_OPERATIONS.REJECT_DIFF: return { ...operation, data: { @@ -145,7 +146,7 @@ export function createInverseOperation(operation: Operation): Operation { }, } - case 'batch-toggle-enabled': + case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED: return { ...operation, data: { @@ -154,7 +155,7 @@ export function createInverseOperation(operation: Operation): Operation { }, } - case 'batch-toggle-handles': + case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES: return { ...operation, data: { From 90269521ba4dc7a9d9c96b96d689f798c3c34762 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 9 Jan 2026 14:46:25 -0800 Subject: [PATCH 35/35] more --- apps/sim/hooks/use-undo-redo.ts | 42 +++++++++++++------------- apps/sim/socket/handlers/operations.ts | 5 ++- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 9eb05c7aa5..740b50293b 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -1336,8 +1336,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'batch-remove-edges', - target: 'edges', + operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + target: OPERATION_TARGETS.EDGES, payload: { edgeIds: affectedEdges.map((e) => e.id) }, }, workflowId: activeWorkflowId, @@ -1358,8 +1358,8 @@ export function useUndoRedo() { addToQueue({ id: crypto.randomUUID(), operation: { - operation: 'batch-add-edges', - target: 'edges', + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, payload: { edges: edgesToAdd }, }, workflowId: activeWorkflowId, @@ -1374,8 +1374,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-update-parent', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT, + target: OPERATION_TARGETS.BLOCKS, payload: { updates: validUpdates.map((u) => ({ id: u.blockId, @@ -1391,7 +1391,7 @@ export function useUndoRedo() { logger.debug('Redid batch-update-parent', { updateCount: validUpdates.length }) break } - case 'batch-toggle-enabled': { + case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_ENABLED: { const toggleOp = entry.operation as BatchToggleEnabledOperation const { blockIds, previousStates } = toggleOp.data @@ -1404,8 +1404,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-toggle-enabled', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, + target: OPERATION_TARGETS.BLOCKS, payload: { blockIds: validBlockIds, previousStates }, }, workflowId: activeWorkflowId, @@ -1419,7 +1419,7 @@ export function useUndoRedo() { }) break } - case 'batch-toggle-handles': { + case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_HANDLES: { const toggleOp = entry.operation as BatchToggleHandlesOperation const { blockIds, previousStates } = toggleOp.data @@ -1432,8 +1432,8 @@ export function useUndoRedo() { addToQueue({ id: opId, operation: { - operation: 'batch-toggle-handles', - target: 'blocks', + operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES, + target: OPERATION_TARGETS.BLOCKS, payload: { blockIds: validBlockIds, previousStates }, }, workflowId: activeWorkflowId, @@ -1447,7 +1447,7 @@ export function useUndoRedo() { }) break } - case 'apply-diff': { + case UNDO_REDO_OPERATIONS.APPLY_DIFF: { // Redo apply-diff means re-applying the proposed state with diff markers const applyDiffOp = entry.operation as any const { proposedState, diffAnalysis, baselineSnapshot } = applyDiffOp.data @@ -1504,7 +1504,7 @@ export function useUndoRedo() { logger.info('Redid apply-diff operation') break } - case 'accept-diff': { + case UNDO_REDO_OPERATIONS.ACCEPT_DIFF: { // Redo accept-diff means re-accepting (stripping markers) const acceptDiffOp = entry.operation as any const { afterAccept } = acceptDiffOp.data @@ -1558,7 +1558,7 @@ export function useUndoRedo() { logger.info('Redid accept-diff operation - cleared diff view') break } - case 'reject-diff': { + case UNDO_REDO_OPERATIONS.REJECT_DIFF: { // Redo reject-diff means re-rejecting (restoring baseline, clearing diff) const rejectDiffOp = entry.operation as any const { afterReject } = rejectDiffOp.data @@ -1636,7 +1636,7 @@ export function useUndoRedo() { const operation: any = { id: crypto.randomUUID(), - type: 'apply-diff', + type: UNDO_REDO_OPERATIONS.APPLY_DIFF, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -1649,7 +1649,7 @@ export function useUndoRedo() { const inverse: any = { id: crypto.randomUUID(), - type: 'apply-diff', + type: UNDO_REDO_OPERATIONS.APPLY_DIFF, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -1680,7 +1680,7 @@ export function useUndoRedo() { const operation: any = { id: crypto.randomUUID(), - type: 'accept-diff', + type: UNDO_REDO_OPERATIONS.ACCEPT_DIFF, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -1694,7 +1694,7 @@ export function useUndoRedo() { const inverse: any = { id: crypto.randomUUID(), - type: 'accept-diff', + type: UNDO_REDO_OPERATIONS.ACCEPT_DIFF, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -1720,7 +1720,7 @@ export function useUndoRedo() { const operation: any = { id: crypto.randomUUID(), - type: 'reject-diff', + type: UNDO_REDO_OPERATIONS.REJECT_DIFF, timestamp: Date.now(), workflowId: activeWorkflowId, userId, @@ -1734,7 +1734,7 @@ export function useUndoRedo() { const inverse: any = { id: crypto.randomUUID(), - type: 'reject-diff', + type: UNDO_REDO_OPERATIONS.REJECT_DIFF, timestamp: Date.now(), workflowId: activeWorkflowId, userId, diff --git a/apps/sim/socket/handlers/operations.ts b/apps/sim/socket/handlers/operations.ts index 0d6b2f349b..9b74293bbf 100644 --- a/apps/sim/socket/handlers/operations.ts +++ b/apps/sim/socket/handlers/operations.ts @@ -6,6 +6,7 @@ import { EDGES_OPERATIONS, OPERATION_TARGETS, VARIABLE_OPERATIONS, + type VariableOperation, WORKFLOW_OPERATIONS, } from '@/socket/constants' import { persistWorkflowOperation } from '@/socket/database/operations' @@ -198,7 +199,9 @@ export function setupOperationsHandlers( if ( target === OPERATION_TARGETS.VARIABLE && - [VARIABLE_OPERATIONS.ADD, VARIABLE_OPERATIONS.REMOVE].includes(operation as any) + ([VARIABLE_OPERATIONS.ADD, VARIABLE_OPERATIONS.REMOVE] as VariableOperation[]).includes( + operation as VariableOperation + ) ) { await persistWorkflowOperation(workflowId, { operation,