improvement(canvas): add multi-block select, add batch handle, enabled, and edge operations

This commit is contained in:
waleed
2026-01-08 18:04:36 -08:00
parent c2180bf8a0
commit 1a5dda6fe3
28 changed files with 1444 additions and 570 deletions

View File

@@ -767,7 +767,7 @@ export default function PrivacyPolicy() {
privacy@sim.ai
</Link>
</li>
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94133, USA</li>
<li>Mailing Address: Sim, 80 Langton St, San Francisco, CA 94103, USA</li>
</ul>
<p>We will respond to your request within a reasonable timeframe.</p>
</section>

View File

@@ -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

View File

@@ -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'

View File

@@ -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;

View File

@@ -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()

View File

@@ -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)]'

View File

@@ -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'

View File

@@ -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 {

View File

@@ -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' &&

View File

@@ -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<string, BlockState>,
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<string, BlockData>,
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<string, BlockData>,
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),
}))
}

View File

@@ -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<string, string>
interface BlockData {
id: string
@@ -200,7 +208,7 @@ interface BlockData {
const WorkflowContent = React.memo(() => {
const [isCanvasReady, setIsCanvasReady] = useState(false)
const [potentialParentId, setPotentialParentId] = useState<string | null>(null)
const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<SelectedEdgeInfo | null>(null)
const [selectedEdges, setSelectedEdges] = useState<SelectedEdgesMap>(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<Map<string, { x: number; y: number; parentId?: string }>>(
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}

View File

@@ -112,7 +112,7 @@ export function EmailFooter({ baseUrl = getBaseUrl(), unsubscribe, messageId }:
</td>
<td style={baseStyles.footerText}>
{brand.name}
{isHosted && <>, 80 Langton St, San Francisco, CA 94133, USA</>}
{isHosted && <>, 80 Langton St, San Francisco, CA 94103, USA</>}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;

View File

@@ -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<string, { x: number; y: number; parentId?: string }>
}
) => {
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<string, boolean> = {}
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<string, boolean> = {}
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<string> }) => {
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<string>()
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,

View File

@@ -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<string, boolean>) => {
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<string, boolean>) => {
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,92 +492,81 @@ 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)
} 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
}
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
const currentBlocks = useWorkflowStore.getState().blocks
if (currentBlocks[moveOp.data.blockId]) {
// Apply the inverse's target as the undo result (inverse.after)
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,
},
},
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'
)
workflowId: activeWorkflowId,
userId,
})
edgesToRemove.forEach((id) => workflowStore.removeEdge(id))
}
logger.debug('Undid add-edge', { edgeCount: edgesToRemove.length })
} else {
logger.debug('Undo move-block skipped; block missing', {
blockId: moveOp.data.blockId,
// 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 })
}
break
}
case 'batch-move-blocks': {
const batchMoveOp = entry.inverse as BatchMoveBlocksOperation
const currentBlocks = useWorkflowStore.getState().blocks
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: 'batch-update-positions',
target: 'blocks',
payload: { updates: positionUpdates },
},
workflowId: activeWorkflowId,
userId,
})
}
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
}
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)) {
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: 'remove',
target: 'edge',
payload: { id: edgeId, isRedo: true, originalOpId: entry.id },
operation: 'batch-add-edges',
target: 'edges',
payload: { edges: edgesToAdd },
},
workflowId: activeWorkflowId,
userId,
})
workflowStore.removeEdge(edgeId)
} else {
logger.debug('Redo remove-edge skipped; edge missing', {
edgeId,
})
edgesToAdd.forEach((edge) => workflowStore.addEdge(edge))
}
break
}
case 'move-block': {
const moveOp = entry.operation as MoveBlockOperation
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: 'batch-remove-edges',
target: 'edges',
payload: { ids: edgesToRemove },
},
workflowId: activeWorkflowId,
userId,
})
edgesToRemove.forEach((id) => workflowStore.removeEdge(id))
}
logger.debug('Redid batch-remove-edges', { edgeCount: edgesToRemove.length })
break
}
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,

View File

@@ -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<string, unknown>) => ({
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,

View File

@@ -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,

View File

@@ -16,6 +16,10 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
'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<string, string[]> = {
'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',

View File

@@ -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,

View File

@@ -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')

View File

@@ -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<UndoRedoState>()(
}
}
// 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))
const sameAfter =
mergedBefore.x === mergedAfter.x &&
mergedBefore.y === mergedAfter.y &&
(mergedBefore.parentId ?? null) === (mergedAfter.parentId ?? null)
// Check if same set of blocks
const sameBlocks =
prevBlockIds.size === incomingBlockIds.size &&
[...prevBlockIds].every((id) => incomingBlockIds.has(id))
const newUndoCoalesced: OperationEntry[] = sameAfter
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,
}
})
// 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[] = 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<UndoRedoState>()(
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

View File

@@ -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<string, boolean>
}
}
export interface BatchToggleHandlesOperation extends BaseOperation {
type: 'batch-toggle-handles'
data: {
blockIds: string[]
previousStates: Record<string, boolean>
}
}
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

View File

@@ -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}`)

View File

@@ -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,

View File

@@ -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 }] },
},
}
}