From fb57c1d33a4bd454e518b474692219b93bcdb9bb Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Thu, 15 Jan 2026 15:36:51 -0800 Subject: [PATCH 1/5] improvement(workflow): ui/ux, refactors, optimizations --- .../workflow-controls/workflow-controls.tsx | 14 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 752 ++++++++---------- 2 files changed, 324 insertions(+), 442 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx index 4b836f085f..a9bd36e0d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls.tsx @@ -126,24 +126,24 @@ export function WorkflowControls() { {mode === 'hand' ? 'Mover' : 'Pointer'} - + { - setMode('cursor') + setMode('hand') setIsCanvasModeOpen(false) }} > - - Pointer + + Mover { - setMode('hand') + setMode('cursor') setIsCanvasModeOpen(false) }} > - - Mover + + Pointer diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 358106d3ae..ec6274a10e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -161,6 +161,24 @@ function calculatePasteOffset( } } +function mapEdgesByNode(edges: Edge[], nodeIds: Set): Map { + const result = new Map() + edges.forEach((edge) => { + if (nodeIds.has(edge.source)) { + const list = result.get(edge.source) ?? [] + list.push(edge) + result.set(edge.source, list) + return + } + if (nodeIds.has(edge.target)) { + const list = result.get(edge.target) ?? [] + list.push(edge) + result.set(edge.target, list) + } + }) + return result +} + /** Custom node types for ReactFlow. */ const nodeTypes: NodeTypes = { workflowBlock: WorkflowBlock, @@ -178,12 +196,16 @@ const edgeTypes: EdgeTypes = { const defaultEdgeOptions = { type: 'custom' } const reactFlowStyles = [ + 'bg-[var(--bg)]', '[&_.react-flow__edges]:!z-0', '[&_.react-flow__node]:!z-[21]', '[&_.react-flow__handle]:!z-[30]', '[&_.react-flow__edge-labels]:!z-[60]', - '[&_.react-flow__pane]:!bg-transparent', - '[&_.react-flow__renderer]:!bg-transparent', + '[&_.react-flow__pane]:!bg-[var(--bg)]', + '[&_.react-flow__pane]:select-none', + '[&_.react-flow__selectionpane]:select-none', + '[&_.react-flow__renderer]:!bg-[var(--bg)]', + '[&_.react-flow__viewport]:!bg-[var(--bg)]', '[&_.react-flow__background]:hidden', ].join(' ') const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const @@ -200,7 +222,6 @@ interface BlockData { id: string type: string position: { x: number; y: number } - distance: number } /** @@ -322,6 +343,59 @@ const WorkflowContent = React.memo(() => { return resizeLoopNodes(updateNodeDimensions) }, [resizeLoopNodes, updateNodeDimensions]) + /** Checks if a node can be placed inside a container (loop/parallel). */ + const canNodeEnterContainer = useCallback( + (node: Node): boolean => { + if (node.data?.type === 'starter') return false + if (node.type === 'subflowNode') return false + const block = blocks[node.id] + return !(block && TriggerUtils.isTriggerBlock(block)) + }, + [blocks] + ) + + /** Shifts position updates to ensure nodes stay within container bounds. */ + const shiftUpdatesToContainerBounds = useCallback( + (rawUpdates: T[]): T[] => { + if (rawUpdates.length === 0) return rawUpdates + + 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 + + if (shiftX === 0 && shiftY === 0) return rawUpdates + + return rawUpdates.map((u) => ({ + ...u, + newPosition: { + x: u.newPosition.x + shiftX, + y: u.newPosition.y + shiftY, + }, + })) + }, + [] + ) + + /** Applies highlight styling to a container node during drag operations. */ + const highlightContainerNode = useCallback( + (containerId: string, containerKind: 'loop' | 'parallel') => { + clearDragHighlights() + const containerElement = document.querySelector(`[data-id="${containerId}"]`) + if (containerElement) { + containerElement.classList.add( + containerKind === 'loop' ? 'loop-node-drag-over' : 'parallel-node-drag-over' + ) + document.body.style.cursor = 'copy' + } + }, + [] + ) + const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null) const isWorkflowEmpty = useMemo(() => Object.keys(blocks).length === 0, [blocks]) @@ -503,6 +577,99 @@ const WorkflowContent = React.memo(() => { [collaborativeBatchUpdateParent] ) + /** + * Executes a batch parent update for nodes being moved into or out of containers. + * Consolidates the common logic used by onNodeDragStop and onSelectionDragStop. + */ + const executeBatchParentUpdate = useCallback( + (nodesToProcess: Node[], targetParentId: string | null, logMessage: string) => { + // Build set of node IDs for efficient lookup + const nodeIds = new Set(nodesToProcess.map((n) => n.id)) + + // Filter to nodes whose parent is actually changing + const nodesNeedingUpdate = nodesToProcess.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 && nodeIds.has(currentParent)) return false + return currentParent !== targetParentId + }) + + if (nodesNeedingUpdate.length === 0) return + + // Filter out nodes that cannot enter containers (when target is a container) + const validNodes = targetParentId + ? nodesNeedingUpdate.filter(canNodeEnterContainer) + : nodesNeedingUpdate + + if (validNodes.length === 0) return + + // Find boundary edges (edges that cross the container 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 boundaryEdgesByNode = mapEdgesByNode(boundaryEdges, movingNodeIds) + + // Build position updates + const rawUpdates = validNodes.map((n) => { + const edgesForThisNode = boundaryEdgesByNode.get(n.id) ?? [] + const newPosition = targetParentId + ? calculateRelativePosition(n.id, targetParentId, true) + : getNodeAbsolutePosition(n.id) + return { + blockId: n.id, + newParentId: targetParentId, + newPosition, + affectedEdges: edgesForThisNode, + } + }) + + // Shift to container bounds if moving into a container + const updates = targetParentId ? shiftUpdatesToContainerBounds(rawUpdates) : rawUpdates + + collaborativeBatchUpdateParent(updates) + + // Update display nodes + setDisplayNodes((nodes) => + nodes.map((node) => { + const update = updates.find((u) => u.blockId === node.id) + if (update) { + return { + ...node, + position: update.newPosition, + parentId: update.newParentId ?? undefined, + } + } + return node + }) + ) + + // Resize container if moving into one + if (targetParentId) { + resizeLoopNodesWrapper() + } + + logger.info(logMessage, { + targetParentId, + nodeCount: validNodes.length, + }) + }, + [ + blocks, + edgesForDisplay, + canNodeEnterContainer, + calculateRelativePosition, + getNodeAbsolutePosition, + shiftUpdatesToContainerBounds, + collaborativeBatchUpdateParent, + resizeLoopNodesWrapper, + ] + ) + const addBlock = useCallback( ( id: string, @@ -515,6 +682,9 @@ const WorkflowContent = React.memo(() => { autoConnectEdge?: Edge, triggerMode?: boolean ) => { + pendingSelectionRef.current = new Set([id]) + setSelectedEdges(new Map()) + const blockData: Record = { ...(data || {}) } if (parentId) blockData.parentId = parentId if (extent) blockData.extent = extent @@ -533,7 +703,7 @@ const WorkflowContent = React.memo(() => { collaborativeBatchAddBlocks([block], autoConnectEdge ? [autoConnectEdge] : [], {}, {}, {}) usePanelEditorStore.getState().setCurrentBlockId(id) }, - [collaborativeBatchAddBlocks] + [collaborativeBatchAddBlocks, setSelectedEdges] ) const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore( @@ -674,104 +844,52 @@ const WorkflowContent = React.memo(() => { copyBlocks(blockIds) }, [contextMenuBlocks, copyBlocks]) - const handleContextPaste = useCallback(() => { - if (!hasClipboard()) return - - const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition) - - const pasteData = preparePasteData(pasteOffset) - if (!pasteData) return - - const { - blocks: pastedBlocks, - edges: pastedEdges, - loops: pastedLoops, - parallels: pastedParallels, - subBlockValues: pastedSubBlockValues, - } = pasteData + /** + * Executes a paste operation with validation and selection handling. + * Consolidates shared logic for context paste, duplicate, and keyboard paste. + */ + const executePasteOperation = useCallback( + (operation: 'paste' | 'duplicate', pasteOffset: { x: number; y: number }) => { + const pasteData = preparePasteData(pasteOffset) + if (!pasteData) return + + const pastedBlocksArray = Object.values(pasteData.blocks) + const validation = validateTriggerPaste(pastedBlocksArray, blocks, operation) + if (!validation.isValid) { + addNotification({ + level: 'error', + message: validation.message!, + workflowId: activeWorkflowId || undefined, + }) + return + } - const pastedBlocksArray = Object.values(pastedBlocks) - const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'paste') - if (!validation.isValid) { - addNotification({ - level: 'error', - message: validation.message!, - workflowId: activeWorkflowId || undefined, - }) - return - } + // Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes) + pendingSelectionRef.current = new Set([ + ...(pendingSelectionRef.current ?? []), + ...pastedBlocksArray.map((b) => b.id), + ]) + + collaborativeBatchAddBlocks( + pastedBlocksArray, + pasteData.edges, + pasteData.loops, + pasteData.parallels, + pasteData.subBlockValues + ) + }, + [preparePasteData, blocks, addNotification, activeWorkflowId, collaborativeBatchAddBlocks] + ) - // Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes) - pendingSelectionRef.current = new Set([ - ...(pendingSelectionRef.current ?? []), - ...pastedBlocksArray.map((b) => b.id), - ]) - - collaborativeBatchAddBlocks( - pastedBlocksArray, - pastedEdges, - pastedLoops, - pastedParallels, - pastedSubBlockValues - ) - }, [ - hasClipboard, - clipboard, - screenToFlowPosition, - preparePasteData, - blocks, - activeWorkflowId, - addNotification, - collaborativeBatchAddBlocks, - ]) + const handleContextPaste = useCallback(() => { + if (!hasClipboard()) return + executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition)) + }, [hasClipboard, executePasteOperation, clipboard, screenToFlowPosition]) const handleContextDuplicate = useCallback(() => { - const blockIds = contextMenuBlocks.map((b) => b.id) - copyBlocks(blockIds) - const pasteData = preparePasteData(DEFAULT_PASTE_OFFSET) - if (!pasteData) return - - const { - blocks: pastedBlocks, - edges: pastedEdges, - loops: pastedLoops, - parallels: pastedParallels, - subBlockValues: pastedSubBlockValues, - } = pasteData - - const pastedBlocksArray = Object.values(pastedBlocks) - const validation = validateTriggerPaste(pastedBlocksArray, blocks, 'duplicate') - if (!validation.isValid) { - addNotification({ - level: 'error', - message: validation.message!, - workflowId: activeWorkflowId || undefined, - }) - return - } - - // Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes) - pendingSelectionRef.current = new Set([ - ...(pendingSelectionRef.current ?? []), - ...pastedBlocksArray.map((b) => b.id), - ]) - - collaborativeBatchAddBlocks( - pastedBlocksArray, - pastedEdges, - pastedLoops, - pastedParallels, - pastedSubBlockValues - ) - }, [ - contextMenuBlocks, - copyBlocks, - preparePasteData, - blocks, - activeWorkflowId, - addNotification, - collaborativeBatchAddBlocks, - ]) + copyBlocks(contextMenuBlocks.map((b) => b.id)) + executePasteOperation('duplicate', DEFAULT_PASTE_OFFSET) + }, [contextMenuBlocks, copyBlocks, executePasteOperation]) const handleContextDelete = useCallback(() => { const blockIds = contextMenuBlocks.map((b) => b.id) @@ -880,36 +998,7 @@ const WorkflowContent = React.memo(() => { } else if ((event.ctrlKey || event.metaKey) && event.key === 'v') { if (effectivePermissions.canEdit && hasClipboard()) { event.preventDefault() - - const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition) - - const pasteData = preparePasteData(pasteOffset) - if (pasteData) { - const pastedBlocks = Object.values(pasteData.blocks) - const validation = validateTriggerPaste(pastedBlocks, blocks, 'paste') - if (!validation.isValid) { - addNotification({ - level: 'error', - message: validation.message!, - workflowId: activeWorkflowId || undefined, - }) - return - } - - // Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes) - pendingSelectionRef.current = new Set([ - ...(pendingSelectionRef.current ?? []), - ...pastedBlocks.map((b) => b.id), - ]) - - collaborativeBatchAddBlocks( - pastedBlocks, - pasteData.edges, - pasteData.loops, - pasteData.parallels, - pasteData.subBlockValues - ) - } + executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition)) } } } @@ -926,15 +1015,11 @@ const WorkflowContent = React.memo(() => { redo, getNodes, copyBlocks, - preparePasteData, - collaborativeBatchAddBlocks, hasClipboard, effectivePermissions.canEdit, - blocks, - addNotification, - activeWorkflowId, clipboard, screenToFlowPosition, + executePasteOperation, ]) /** @@ -962,34 +1047,42 @@ const WorkflowContent = React.memo(() => { const containerAtPoint = isPointInLoopNode(newNodePosition) const nodeIndex = new Map(getNodes().map((n) => [n.id, n])) - const candidates = Object.entries(blocks) - .filter(([id, block]) => { - if (!block.enabled) return false - if (block.type === 'response') return false - const node = nodeIndex.get(id) - if (!node) return false - - const blockParentId = blocks[id]?.data?.parentId - const dropParentId = containerAtPoint?.loopId - if (dropParentId !== blockParentId) return false - - return true - }) - .map(([id, block]) => { - const anchor = getNodeAnchorPosition(id) - const distance = Math.sqrt( - (anchor.x - newNodePosition.x) ** 2 + (anchor.y - newNodePosition.y) ** 2 - ) + const closest = Object.entries(blocks).reduce<{ + id: string + type: string + position: { x: number; y: number } + distanceSquared: number + } | null>((acc, [id, block]) => { + if (!block.enabled) return acc + if (block.type === 'response') return acc + const node = nodeIndex.get(id) + if (!node) return acc + + const blockParentId = blocks[id]?.data?.parentId + const dropParentId = containerAtPoint?.loopId + if (dropParentId !== blockParentId) return acc + + const anchor = getNodeAnchorPosition(id) + const distanceSquared = + (anchor.x - newNodePosition.x) ** 2 + (anchor.y - newNodePosition.y) ** 2 + if (!acc || distanceSquared < acc.distanceSquared) { return { id, type: block.type, position: anchor, - distance, + distanceSquared, } - }) - .sort((a, b) => a.distance - b.distance) + } + return acc + }, null) + + if (!closest) return null - return candidates[0] || null + return { + id: closest.id, + type: closest.type, + position: closest.position, + } }, [blocks, getNodes, getNodeAnchorPosition, isPointInLoopNode] ) @@ -1053,15 +1146,28 @@ const WorkflowContent = React.memo(() => { candidateBlocks: { id: string; type: string; position: { x: number; y: number } }[], targetPosition: { x: number; y: number } ): { id: string; type: string; position: { x: number; y: number } } | undefined => { - return candidateBlocks - .filter((b) => b.type !== 'response') - .map((b) => ({ - block: b, - distance: Math.sqrt( - (b.position.x - targetPosition.x) ** 2 + (b.position.y - targetPosition.y) ** 2 - ), - })) - .sort((a, b) => a.distance - b.distance)[0]?.block + const closest = candidateBlocks.reduce<{ + id: string + type: string + position: { x: number; y: number } + distanceSquared: number + } | null>((acc, block) => { + if (block.type === 'response') return acc + const distanceSquared = + (block.position.x - targetPosition.x) ** 2 + (block.position.y - targetPosition.y) ** 2 + if (!acc || distanceSquared < acc.distanceSquared) { + return { ...block, distanceSquared } + } + return acc + }, null) + + return closest + ? { + id: closest.id, + type: closest.type, + position: closest.position, + } + : undefined }, [] ) @@ -1637,39 +1743,27 @@ const WorkflowContent = React.memo(() => { // Check if hovering over a container node const containerInfo = isPointInLoopNode(position) - // Clear any previous highlighting - clearDragHighlights() - // Highlight container if hovering over it and not dragging a subflow // Subflow drag is marked by body class flag set by toolbar const isSubflowDrag = document.body.classList.contains('sim-drag-subflow') if (containerInfo && !isSubflowDrag) { - const containerElement = document.querySelector(`[data-id="${containerInfo.loopId}"]`) - if (containerElement) { - // Determine the type of container node for appropriate styling - const containerNode = getNodes().find((n) => n.id === containerInfo.loopId) - if ( - containerNode?.type === 'subflowNode' && - (containerNode.data as SubflowNodeData)?.kind === 'loop' - ) { - containerElement.classList.add('loop-node-drag-over') - } else if ( - containerNode?.type === 'subflowNode' && - (containerNode.data as SubflowNodeData)?.kind === 'parallel' - ) { - containerElement.classList.add('parallel-node-drag-over') + const containerNode = getNodes().find((n) => n.id === containerInfo.loopId) + if (containerNode?.type === 'subflowNode') { + const kind = (containerNode.data as SubflowNodeData)?.kind + if (kind === 'loop' || kind === 'parallel') { + highlightContainerNode(containerInfo.loopId, kind) } - document.body.style.cursor = 'copy' } } else { + clearDragHighlights() document.body.style.cursor = '' } } catch (err) { logger.error('Error in onDragOver', { err }) } }, - [screenToFlowPosition, isPointInLoopNode, getNodes] + [screenToFlowPosition, isPointInLoopNode, getNodes, highlightContainerNode] ) const loadingWorkflowRef = useRef(null) @@ -1974,6 +2068,7 @@ const WorkflowContent = React.memo(() => { const targetInSelection = movingNodeIds.has(e.target) return sourceInSelection !== targetInSelection }) + const boundaryEdgesByNode = mapEdgesByNode(boundaryEdges, movingNodeIds) // Collect absolute positions BEFORE any mutations const absolutePositions = new Map() @@ -1984,9 +2079,7 @@ const WorkflowContent = React.memo(() => { // Build batch update with all blocks and their affected edges const updates = validBlockIds.map((blockId) => { const absolutePosition = absolutePositions.get(blockId)! - const edgesForThisNode = boundaryEdges.filter( - (e) => e.source === blockId || e.target === blockId - ) + const edgesForThisNode = boundaryEdgesByNode.get(blockId) ?? [] return { blockId, newParentId: null, @@ -2443,23 +2536,9 @@ const WorkflowContent = React.memo(() => { setPotentialParentId(bestContainerMatch.container.id) // Add highlight class and change cursor - const containerElement = document.querySelector( - `[data-id="${bestContainerMatch.container.id}"]` - ) - if (containerElement) { - // Apply appropriate class based on container type - if ( - bestContainerMatch.container.type === 'subflowNode' && - (bestContainerMatch.container.data as SubflowNodeData)?.kind === 'loop' - ) { - containerElement.classList.add('loop-node-drag-over') - } else if ( - bestContainerMatch.container.type === 'subflowNode' && - (bestContainerMatch.container.data as SubflowNodeData)?.kind === 'parallel' - ) { - containerElement.classList.add('parallel-node-drag-over') - } - document.body.style.cursor = 'copy' + const kind = (bestContainerMatch.container.data as SubflowNodeData)?.kind + if (kind === 'loop' || kind === 'parallel') { + highlightContainerNode(bestContainerMatch.container.id, kind) } } else { // Remove highlighting if no longer over a container @@ -2476,6 +2555,7 @@ const WorkflowContent = React.memo(() => { getNodeAbsolutePosition, getNodeDepth, updateContainerDimensionsDuringDrag, + highlightContainerNode, ] ) @@ -2529,103 +2609,12 @@ const WorkflowContent = React.memo(() => { previousPositions: multiNodeDragStartRef.current, }) - // 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) { - 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 edgesForThisNode = boundaryEdges.filter( - (e) => e.source === n.id || e.target === n.id - ) - const newPosition = potentialParentId - ? calculateRelativePosition(n.id, potentialParentId, true) - : getNodeAbsolutePosition(n.id) - return { - blockId: n.id, - newParentId: potentialParentId, - newPosition, - affectedEdges: edgesForThisNode, - } - }) - - let updates = rawUpdates - if (potentialParentId) { - 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 - - 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 ?? undefined, - } - } - return node - }) - ) - - if (potentialParentId) { - resizeLoopNodesWrapper() - } - - logger.info('Batch moved nodes to new parent', { - targetParentId: potentialParentId, - nodeCount: validNodes.length, - }) - } - } + // Process parent updates using shared helper + executeBatchParentUpdate( + selectedNodes, + potentialParentId, + 'Batch moved nodes to new parent' + ) // Clear drag start state setDragStartPosition(null) @@ -2818,31 +2807,15 @@ const WorkflowContent = React.memo(() => { edgesForDisplay, removeEdgesForNode, getNodeAbsolutePosition, - calculateRelativePosition, - resizeLoopNodesWrapper, getDragStartPosition, setDragStartPosition, addNotification, activeWorkflowId, collaborativeBatchUpdatePositions, - collaborativeBatchUpdateParent, + executeBatchParentUpdate, ] ) - // // Lock selection mode when selection drag starts (captures Shift state at drag start) - // const onSelectionStart = useCallback(() => { - // if (isShiftPressed) { - // setIsSelectionDragActive(true) - // } - // }, [isShiftPressed]) - - // const onSelectionEnd = useCallback(() => { - // requestAnimationFrame(() => { - // setIsSelectionDragActive(false) - // setDisplayNodes((nodes) => resolveParentChildSelectionConflicts(nodes, blocks)) - // }) - // }, [blocks]) - /** Captures initial positions when selection drag starts (for marquee-selected nodes). */ const onSelectionDragStart = useCallback( (_event: React.MouseEvent, nodes: Node[]) => { @@ -2883,13 +2856,7 @@ const WorkflowContent = React.memo(() => { 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 - }) + const eligibleNodes = nodes.filter(canNodeEnterContainer) // If no eligible nodes, clear any potential parent if (eligibleNodes.length === 0) { @@ -2969,18 +2936,12 @@ const WorkflowContent = React.memo(() => { 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' + const kind = (bestMatch.container.data as SubflowNodeData)?.kind + if (kind === 'loop' || kind === 'parallel') { + highlightContainerNode(bestMatch.container.id, kind) } } } else if (potentialParentId) { @@ -2989,12 +2950,13 @@ const WorkflowContent = React.memo(() => { } }, [ - blocks, + canNodeEnterContainer, getNodes, potentialParentId, getNodeAbsolutePosition, getNodeDepth, clearDragHighlights, + highlightContainerNode, ] ) @@ -3009,102 +2971,8 @@ const WorkflowContent = React.memo(() => { previousPositions: multiNodeDragStartRef.current, }) - // 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) { - 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 edgesForThisNode = boundaryEdges.filter( - (e) => e.source === n.id || e.target === n.id - ) - const newPosition = potentialParentId - ? calculateRelativePosition(n.id, potentialParentId, true) - : getNodeAbsolutePosition(n.id) - return { - blockId: n.id, - newParentId: potentialParentId, - newPosition, - affectedEdges: edgesForThisNode, - } - }) - - let updates = rawUpdates - if (potentialParentId) { - 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 - - 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 ?? undefined, - } - } - return node - }) - ) - - if (potentialParentId) { - resizeLoopNodesWrapper() - } - - logger.info('Batch moved selection to new parent', { - targetParentId: potentialParentId, - nodeCount: validNodes.length, - }) - } - } + // Process parent updates using shared helper + executeBatchParentUpdate(nodes, potentialParentId, 'Batch moved selection to new parent') // Clear drag state setDragStartPosition(null) @@ -3114,14 +2982,10 @@ const WorkflowContent = React.memo(() => { [ blocks, getNodes, - getNodeAbsolutePosition, collaborativeBatchUpdatePositions, - collaborativeBatchUpdateParent, - calculateRelativePosition, - resizeLoopNodesWrapper, potentialParentId, - edgesForDisplay, clearDragHighlights, + executeBatchParentUpdate, ] ) @@ -3130,6 +2994,23 @@ const WorkflowContent = React.memo(() => { usePanelEditorStore.getState().clearCurrentBlock() }, []) + /** Prevents native text selection when starting a shift-drag on the pane. */ + const handleCanvasMouseDown = useCallback((event: React.MouseEvent) => { + if (!event.shiftKey) return + + const target = event.target as HTMLElement | null + if (!target) return + + const isPaneTarget = Boolean(target.closest('.react-flow__pane, .react-flow__selectionpane')) + if (!isPaneTarget) return + + event.preventDefault() + const selection = window.getSelection() + if (selection && selection.rangeCount > 0) { + selection.removeAllRanges() + } + }, []) + /** * Handles node click to select the node in ReactFlow. * Parent-child conflict resolution happens automatically in onNodesChange. @@ -3303,6 +3184,7 @@ const WorkflowContent = React.memo(() => { onConnectEnd={effectivePermissions.canEdit ? onConnectEnd : undefined} nodeTypes={nodeTypes} edgeTypes={edgeTypes} + onMouseDown={handleCanvasMouseDown} onDrop={effectivePermissions.canEdit ? onDrop : undefined} onDragOver={effectivePermissions.canEdit ? onDragOver : undefined} onInit={(instance) => { From e7076531a9fa4149d202704e0eeb69ef753f65ba Mon Sep 17 00:00:00 2001 From: Emir Karabeg Date: Thu, 15 Jan 2026 15:52:54 -0800 Subject: [PATCH 2/5] improvement: blocks, preview, avatars --- .../components/general/general.tsx | 1 - .../components/connections/connections.tsx | 23 ------ .../workflow-block/components/index.ts | 1 - .../workflow-block/workflow-block.tsx | 7 +- .../components/block-details-sidebar.tsx | 5 -- .../w/components/preview/preview.tsx | 19 +++-- .../components/folder-item/folder-item.tsx | 4 +- .../workflow-item/avatars/avatars.tsx | 18 +--- .../workflow-item/workflow-item.tsx | 82 +++++++++---------- 9 files changed, 57 insertions(+), 103 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/connections/connections.tsx 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 c9114a2d7f..218e032f1e 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 @@ -334,7 +334,6 @@ export function GeneralDeploy({ }} onPaneClick={() => setExpandedSelectedBlockId(null)} selectedBlockId={expandedSelectedBlockId} - lightweight /> {expandedSelectedBlockId && workflowToShow.blocks?.[expandedSelectedBlockId] && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/connections/connections.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/connections/connections.tsx deleted file mode 100644 index fcca532fb9..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/connections/connections.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useBlockConnections } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-block-connections' - -interface ConnectionsProps { - blockId: string -} - -/** - * Displays incoming connections at the bottom left of the workflow block - */ -export function Connections({ blockId }: ConnectionsProps) { - const { incomingConnections, hasIncomingConnections } = useBlockConnections(blockId) - - if (!hasIncomingConnections) return null - - const connectionCount = incomingConnections.length - const connectionText = `${connectionCount} ${connectionCount === 1 ? 'connection' : 'connections'}` - - return ( -
- {connectionText} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/index.ts index 2329ca5b5f..d1dc2023da 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/index.ts @@ -1,2 +1 @@ export { ActionBar } from './action-bar/action-bar' -export { Connections } from './connections/connections' 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 761b5c6182..24c03ad57a 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 @@ -9,10 +9,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { createMcpToolId } from '@/lib/mcp/utils' import { getProviderIdFromServiceId } from '@/lib/oauth' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { - ActionBar, - Connections, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components' +import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components' import { useBlockProperties, useChildWorkflow, @@ -934,8 +931,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({ )} - {shouldShowDefaultHandles && } - {shouldShowDefaultHandles && ( {block.name || blockConfig.name} - {block.enabled === false && ( - - Disabled - - )} {onClose && ( - + )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx index 506b9a6e24..7ffcf90859 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/avatars/avatars.tsx @@ -1,6 +1,6 @@ 'use client' -import { type CSSProperties, useEffect, useMemo } from 'react' +import { type CSSProperties, useMemo } from 'react' import { Avatar, AvatarFallback, AvatarImage, Tooltip } from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' import { getUserColor } from '@/lib/workspaces/colors' @@ -19,11 +19,6 @@ const AVATAR_CONFIG = { interface AvatarsProps { workflowId: string - /** - * Callback fired when the presence visibility changes. - * Used by parent components to adjust layout (e.g., text truncation spacing). - */ - onPresenceChange?: (hasAvatars: boolean) => void } interface PresenceUser { @@ -85,7 +80,7 @@ function UserAvatar({ user, index }: UserAvatarProps) { * @param props - Component props * @returns Avatar stack for workflow presence */ -export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) { +export function Avatars({ workflowId }: AvatarsProps) { const { presenceUsers, currentWorkflowId } = useSocket() const { data: session } = useSession() const currentUserId = session?.user?.id @@ -127,19 +122,12 @@ export function Avatars({ workflowId, onPresenceChange }: AvatarsProps) { return { visibleUsers: visible, overflowCount: overflow } }, [workflowUsers, maxVisible]) - useEffect(() => { - const hasAnyAvatars = visibleUsers.length > 0 - if (typeof onPresenceChange === 'function') { - onPresenceChange(hasAnyAvatars) - } - }, [visibleUsers, onPresenceChange]) - if (visibleUsers.length === 0) { return null } return ( -
+
{overflowCount > 0 && ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index ddea49b135..8c3e7c2663 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -64,8 +64,6 @@ export function WorkflowItem({ const [deleteModalNames, setDeleteModalNames] = useState('') const [canDeleteCaptured, setCanDeleteCaptured] = useState(true) - const [hasAvatars, setHasAvatars] = useState(false) - const capturedSelectionRef = useRef<{ workflowIds: string[] workflowNames: string | string[] @@ -319,48 +317,50 @@ export function WorkflowItem({ className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]' style={{ backgroundColor: workflow.color }} /> -
- {isEditing ? ( - setEditValue(e.target.value)} - onKeyDown={handleKeyDown} - onBlur={handleInputBlur} - className={clsx( - 'w-full border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0', - active - ? 'text-[var(--text-primary)]' - : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]' - )} - maxLength={100} - disabled={isRenaming} - onClick={(e) => { - e.preventDefault() - e.stopPropagation() - }} - autoComplete='off' - autoCorrect='off' - autoCapitalize='off' - spellCheck='false' - /> - ) : ( -
- {workflow.name} -
- )} +
+
+ {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleInputBlur} + className={clsx( + 'w-full min-w-0 border-0 bg-transparent p-0 font-medium text-[14px] outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0', + active + ? 'text-[var(--text-primary)]' + : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-primary)]' + )} + maxLength={100} + disabled={isRenaming} + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + }} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck='false' + /> + ) : ( +
+ {workflow.name} +
+ )} + {!isEditing && } +
{!isEditing && ( <> -
Date: Thu, 15 Jan 2026 16:41:31 -0800 Subject: [PATCH 5/5] improvement: subflow ui/ux --- .../components => }/action-bar/action-bar.tsx | 7 ++-- .../components/note-block/note-block.tsx | 2 +- .../components/subflows/subflow-node.tsx | 34 +++++++++---------- .../workflow-block/components/index.ts | 1 - .../workflow-block/workflow-block.tsx | 2 +- 5 files changed, 22 insertions(+), 24 deletions(-) rename apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/{workflow-block/components => }/action-bar/action-bar.tsx (97%) delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/index.ts 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/action-bar/action-bar.tsx similarity index 97% rename from apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx rename to apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index 615d91aad1..57cfc4f4d1 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/action-bar/action-bar.tsx @@ -100,6 +100,7 @@ export const ActionBar = memo( const isStartBlock = blockType === 'starter' || blockType === 'start_trigger' const isResponseBlock = blockType === 'response' const isNoteBlock = blockType === 'note' + const isSubflowBlock = blockType === 'loop' || blockType === 'parallel' /** * Get appropriate tooltip message based on disabled state @@ -125,7 +126,7 @@ export const ActionBar = memo( 'dark:border-transparent dark:bg-[var(--surface-4)]' )} > - {!isNoteBlock && ( + {!isNoteBlock && !isSubflowBlock && (