feat(undo-redo): undo/redo for canvas editing (#1392)

* feat(undo-redo): support undo-redo on canvas

* fix zoom live subscribe

* progress

* fix subflows

* progress

* fix subflow logic

* pruning stacks

* centralize unique naming logic

* fix type issues

* address greptile comments

* remove timeouts
This commit is contained in:
Vikhyath Mondreti
2025-09-22 16:38:13 -07:00
committed by GitHub
parent 7cb303e713
commit 2f97782df0
14 changed files with 2635 additions and 86 deletions
@@ -0,0 +1,141 @@
'use client'
import { Minus, Plus, Redo2, Undo2 } from 'lucide-react'
import { useReactFlow, useStore } from 'reactflow'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { cn } from '@/lib/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useGeneralStore } from '@/stores/settings/general/store'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
export function FloatingControls() {
const { zoomIn, zoomOut } = useReactFlow()
// Subscribe to React Flow store so zoom % live-updates while zooming
const zoom = useStore((s: any) =>
Array.isArray(s.transform) ? s.transform[2] : s.viewport?.zoom
)
const { undo, redo } = useCollaborativeWorkflow()
const { showFloatingControls } = useGeneralStore()
const { activeWorkflowId } = useWorkflowRegistry()
const { data: session } = useSession()
const userId = session?.user?.id || 'unknown'
const stacks = useUndoRedoStore((s) => s.stacks)
const undoRedoSizes = (() => {
const key = activeWorkflowId && userId ? `${activeWorkflowId}:${userId}` : ''
const stack = (key && stacks[key]) || { undo: [], redo: [] }
return { undoSize: stack.undo.length, redoSize: stack.redo.length }
})()
const currentZoom = Math.round(((zoom as number) || 1) * 100)
if (!showFloatingControls) return null
const handleZoomIn = () => {
zoomIn({ duration: 200 })
}
const handleZoomOut = () => {
zoomOut({ duration: 200 })
}
return (
<div className='-translate-x-1/2 fixed bottom-6 left-1/2 z-10'>
<div className='flex items-center gap-1 rounded-[14px] border bg-card/95 p-1 shadow-lg backdrop-blur-sm'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={handleZoomOut}
disabled={currentZoom <= 10}
className={cn(
'h-9 w-9 rounded-[10px]',
'hover:bg-muted/80',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
<Minus className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>Zoom Out</TooltipContent>
</Tooltip>
<div className='flex w-12 items-center justify-center font-medium text-muted-foreground text-sm'>
{currentZoom}%
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={handleZoomIn}
disabled={currentZoom >= 200}
className={cn(
'h-9 w-9 rounded-[10px]',
'hover:bg-muted/80',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
<Plus className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>Zoom In</TooltipContent>
</Tooltip>
<div className='mx-1 h-6 w-px bg-border' />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={undo}
disabled={undoRedoSizes.undoSize === 0}
className={cn(
'h-9 w-9 rounded-[10px]',
'hover:bg-muted/80',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
<Undo2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>
<div className='text-center'>
<p>Undo</p>
<p className='text-muted-foreground text-xs'>Cmd+Z</p>
</div>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
onClick={redo}
disabled={undoRedoSizes.redoSize === 0}
className={cn(
'h-9 w-9 rounded-[10px]',
'hover:bg-muted/80',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
<Redo2 className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent>
<div className='text-center'>
<p>Redo</p>
<p className='text-muted-foreground text-xs'>Cmd+Shift+Z</p>
</div>
</TooltipContent>
</Tooltip>
</div>
</div>
)
}
@@ -17,6 +17,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar'
import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { FloatingControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/floating-controls/floating-controls'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
@@ -39,6 +40,7 @@ import { useExecutionStore } from '@/stores/execution/store'
import { useGeneralStore } from '@/stores/settings/general/store'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { hasWorkflowsInitiallyLoaded, useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { getUniqueBlockName } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const logger = createLogger('Workflow')
@@ -89,7 +91,12 @@ const WorkflowContent = React.memo(() => {
// Use the clean abstraction for current workflow state
const currentWorkflow = useCurrentWorkflow()
const { updateNodeDimensions, updateBlockPosition: storeUpdateBlockPosition } = useWorkflowStore()
const {
updateNodeDimensions,
updateBlockPosition: storeUpdateBlockPosition,
setDragStartPosition,
getDragStartPosition,
} = useWorkflowStore()
// Get copilot cleanup function
const copilotCleanup = useCopilotStore((state) => state.cleanup)
@@ -175,6 +182,8 @@ const WorkflowContent = React.memo(() => {
collaborativeUpdateBlockPosition,
collaborativeUpdateParentId: updateParentId,
collaborativeSetSubblockValue,
undo,
redo,
} = useCollaborativeWorkflow()
// Execution and debug mode state
@@ -229,10 +238,50 @@ const WorkflowContent = React.memo(() => {
}
}, [permissionsError, workspaceId])
// Helper function to update a node's parent with proper position calculation
const updateNodeParent = useCallback(
(nodeId: string, newParentId: string | null) => {
return updateNodeParentUtil(
(nodeId: string, newParentId: string | null, affectedEdges: any[] = []) => {
const node = getNodes().find((n: any) => n.id === nodeId)
if (!node) return
const currentBlock = blocks[nodeId]
if (!currentBlock) return
const oldParentId = node.parentId || currentBlock.data?.parentId
const oldPosition = { ...node.position }
// affectedEdges are edges that are either being removed (when leaving a subflow)
// or being added (when entering a subflow)
if (!affectedEdges.length && !newParentId && oldParentId) {
affectedEdges = edgesForDisplay.filter((e) => e.source === nodeId || e.target === nodeId)
}
let newPosition = oldPosition
if (newParentId) {
const getNodeAbsolutePosition = (id: string): { x: number; y: number } => {
const n = getNodes().find((node: any) => node.id === id)
if (!n) return { x: 0, y: 0 }
if (!n.parentId) return n.position
const parentPos = getNodeAbsolutePosition(n.parentId)
return { x: parentPos.x + n.position.x, y: parentPos.y + n.position.y }
}
const nodeAbsPos = getNodeAbsolutePosition(nodeId)
const parentAbsPos = getNodeAbsolutePosition(newParentId)
newPosition = {
x: nodeAbsPos.x - parentAbsPos.x,
y: nodeAbsPos.y - parentAbsPos.y,
}
} else if (oldParentId) {
const getNodeAbsolutePosition = (id: string): { x: number; y: number } => {
const n = getNodes().find((node: any) => node.id === id)
if (!n) return { x: 0, y: 0 }
if (!n.parentId) return n.position
const parentPos = getNodeAbsolutePosition(n.parentId)
return { x: parentPos.x + n.position.x, y: parentPos.y + n.position.y }
}
newPosition = getNodeAbsolutePosition(nodeId)
}
const result = updateNodeParentUtil(
nodeId,
newParentId,
getNodes,
@@ -240,8 +289,32 @@ const WorkflowContent = React.memo(() => {
updateParentId,
() => resizeLoopNodes(getNodes, updateNodeDimensions, blocks)
)
if (oldParentId !== newParentId) {
window.dispatchEvent(
new CustomEvent('workflow-record-parent-update', {
detail: {
blockId: nodeId,
oldParentId: oldParentId || undefined,
newParentId: newParentId || undefined,
oldPosition,
newPosition,
affectedEdges: affectedEdges.map((e) => ({ ...e })),
},
})
)
}
return result
},
[getNodes, collaborativeUpdateBlockPosition, updateParentId, updateNodeDimensions, blocks]
[
getNodes,
collaborativeUpdateBlockPosition,
updateParentId,
updateNodeDimensions,
blocks,
edgesForDisplay,
]
)
// Function to resize all loop nodes with improved hierarchy handling
@@ -345,23 +418,29 @@ 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) {
return
}
if (event.shiftKey && event.key === 'L' && !event.ctrlKey && !event.metaKey) {
// Don't trigger if user is typing in an input, textarea, or contenteditable element
const activeElement = document.activeElement
const isEditableElement =
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.hasAttribute('contenteditable')
if (isEditableElement) {
return // Allow normal typing behavior
}
event.preventDefault()
if (cleanup) cleanup()
cleanup = debouncedAutoLayout()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z' && !event.shiftKey) {
event.preventDefault()
undo()
} else if (
(event.ctrlKey || event.metaKey) &&
(event.key === 'Z' || (event.key === 'z' && event.shiftKey))
) {
event.preventDefault()
redo()
}
}
@@ -371,7 +450,7 @@ const WorkflowContent = React.memo(() => {
window.removeEventListener('keydown', handleKeyDown)
if (cleanup) cleanup()
}
}, [debouncedAutoLayout])
}, [debouncedAutoLayout, undo, redo])
// Listen for explicit remove-from-subflow actions from ActionBar
useEffect(() => {
@@ -381,17 +460,28 @@ const WorkflowContent = React.memo(() => {
if (!blockId) return
try {
// Remove parent-child relationship while preserving absolute position
updateNodeParent(blockId, null)
const currentBlock = blocks[blockId]
const parentId = currentBlock?.data?.parentId
// Remove all edges connected to this block
const connectedEdges = edgesForDisplay.filter(
if (!parentId) return
// Find ALL edges connected to this block
const edgesToRemove = edgesForDisplay.filter(
(e) => e.source === blockId || e.target === blockId
)
connectedEdges.forEach((edge) => {
// Set flag to skip individual edge recording for undo/redo
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
// Remove edges first
edgesToRemove.forEach((edge) => {
removeEdge(edge.id)
})
// Then update parent relationship
updateNodeParent(blockId, null, edgesToRemove)
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } }))
} catch (err) {
logger.error('Failed to remove from subflow', { err })
}
@@ -485,10 +575,8 @@ const WorkflowContent = React.memo(() => {
// Create a unique ID and name for the container
const id = crypto.randomUUID()
// Auto-number the blocks based on existing blocks of the same type
const existingBlocksOfType = Object.values(blocks).filter((b) => b.type === type)
const blockNumber = existingBlocksOfType.length + 1
const name = type === 'loop' ? `Loop ${blockNumber}` : `Parallel ${blockNumber}`
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks)
// Calculate the center position of the viewport
const centerPosition = project({
@@ -549,7 +637,7 @@ const WorkflowContent = React.memo(() => {
// Create a new block with a unique ID
const id = crypto.randomUUID()
const name = `${blockConfig.name} ${Object.values(blocks).filter((b) => b.type === type).length + 1}`
const name = getUniqueBlockName(blockConfig.name, blocks)
// Auto-connect logic
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
@@ -626,10 +714,8 @@ const WorkflowContent = React.memo(() => {
// Create a unique ID and name for the container
const id = crypto.randomUUID()
// Auto-number the blocks based on existing blocks of the same type
const existingBlocksOfType = Object.values(blocks).filter((b) => b.type === data.type)
const blockNumber = existingBlocksOfType.length + 1
const name = data.type === 'loop' ? `Loop ${blockNumber}` : `Parallel ${blockNumber}`
const baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
const name = getUniqueBlockName(baseName, blocks)
// Check if we're dropping inside another container
if (containerInfo) {
@@ -698,12 +784,9 @@ const WorkflowContent = React.memo(() => {
// Generate id and name here so they're available in all code paths
const id = crypto.randomUUID()
const name =
data.type === 'loop'
? `Loop ${Object.values(blocks).filter((b) => b.type === 'loop').length + 1}`
: data.type === 'parallel'
? `Parallel ${Object.values(blocks).filter((b) => b.type === 'parallel').length + 1}`
: `${blockConfig!.name} ${Object.values(blocks).filter((b) => b.type === data.type).length + 1}`
const baseName =
data.type === 'loop' ? 'Loop' : data.type === 'parallel' ? 'Parallel' : blockConfig!.name
const name = getUniqueBlockName(baseName, blocks)
if (containerInfo) {
// Calculate position relative to the container node
@@ -717,18 +800,9 @@ const WorkflowContent = React.memo(() => {
(b) => b.data?.parentId === containerInfo.loopId
)
// Add block with parent info
addBlock(id, data.type, name, relativePosition, {
parentId: containerInfo.loopId,
extent: 'parent',
})
// Resize the container node to fit the new block
// Immediate resize without delay
resizeLoopNodesWrapper()
// Auto-connect logic for blocks inside containers
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
let autoConnectEdge
if (isAutoConnectEnabled && data.type !== 'starter') {
if (existingChildBlocks.length > 0) {
// Connect to the nearest existing child block within the container
@@ -747,14 +821,14 @@ const WorkflowContent = React.memo(() => {
id: closestBlock.id,
type: closestBlock.type,
})
addEdge({
autoConnectEdge = {
id: crypto.randomUUID(),
source: closestBlock.id,
target: id,
sourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
}
} else {
// No existing children: connect from the container's start handle
@@ -764,16 +838,35 @@ const WorkflowContent = React.memo(() => {
? 'loop-start-source'
: 'parallel-start-source'
addEdge({
autoConnectEdge = {
id: crypto.randomUUID(),
source: containerInfo.loopId,
target: id,
sourceHandle: startSourceHandle,
targetHandle: 'target',
type: 'workflowEdge',
})
}
}
}
// Add block with parent info AND autoConnectEdge (atomic operation)
addBlock(
id,
data.type,
name,
relativePosition,
{
parentId: containerInfo.loopId,
extent: 'parent',
},
containerInfo.loopId,
'parent',
autoConnectEdge
)
// Resize the container node to fit the new block
// Immediate resize without delay
resizeLoopNodesWrapper()
} else {
// Regular auto-connect logic
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
@@ -1349,8 +1442,15 @@ const WorkflowContent = React.memo(() => {
// Store the original parent ID when starting to drag
const currentParentId = node.parentId || blocks[node.id]?.data?.parentId || null
setDragStartParentId(currentParentId)
// Store starting position for undo/redo move entry
setDragStartPosition({
id: node.id,
x: node.position.x,
y: node.position.y,
parentId: currentParentId,
})
},
[blocks]
[blocks, setDragStartPosition]
)
// Handle node drag stop to establish parent-child relationships
@@ -1366,6 +1466,29 @@ const WorkflowContent = React.memo(() => {
// This ensures other users see the smooth final position
collaborativeUpdateBlockPosition(node.id, node.position)
// Record single move entry on drag end to avoid micro-moves
try {
const start = getDragStartPosition()
if (start && start.id === node.id) {
const before = { x: start.x, y: start.y, parentId: start.parentId }
const after = {
x: node.position.x,
y: node.position.y,
parentId: node.parentId || blocks[node.id]?.data?.parentId,
}
const moved =
before.x !== after.x || before.y !== after.y || before.parentId !== after.parentId
if (moved) {
window.dispatchEvent(
new CustomEvent('workflow-record-move', {
detail: { blockId: node.id, before, after },
})
)
}
setDragStartPosition(null)
}
} catch {}
// Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent
if (potentialParentId === dragStartParentId) return
@@ -1409,8 +1532,8 @@ const WorkflowContent = React.memo(() => {
y: nodeAbsPosBefore.y - containerAbsPosBefore.y,
}
// Moving to a new parent container
updateNodeParent(node.id, potentialParentId)
// Prepare edges that will be added when moving into the container
const edgesToAdd: any[] = []
// Auto-connect when moving an existing block into a container
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
@@ -1437,7 +1560,7 @@ const WorkflowContent = React.memo(() => {
id: closestBlock.id,
type: closestBlock.type,
})
addEdge({
edgesToAdd.push({
id: crypto.randomUUID(),
source: closestBlock.id,
target: node.id,
@@ -1454,7 +1577,7 @@ const WorkflowContent = React.memo(() => {
? 'loop-start-source'
: 'parallel-start-source'
addEdge({
edgesToAdd.push({
id: crypto.randomUUID(),
source: potentialParentId,
target: node.id,
@@ -1464,6 +1587,17 @@ const WorkflowContent = React.memo(() => {
})
}
}
// Skip recording these edges separately since they're part of the parent update
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
// Moving to a new parent container - pass the edges that will be added
updateNodeParent(node.id, potentialParentId, edgesToAdd)
// Now add the edges after parent update
edgesToAdd.forEach((edge) => addEdge(edge))
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } }))
}
// Reset state
@@ -1481,6 +1615,8 @@ const WorkflowContent = React.memo(() => {
determineSourceHandle,
blocks,
getNodeAbsolutePositionWrapper,
getDragStartPosition,
setDragStartPosition,
]
)
@@ -1620,6 +1756,9 @@ const WorkflowContent = React.memo(() => {
{/* Floating Control Bar */}
<ControlBar hasValidationErrors={nestedSubflowErrors.size > 0} />
{/* Floating Controls (Zoom, Undo, Redo) */}
<FloatingControls />
<ReactFlow
nodes={nodes}
edges={edgesWithSelection}
@@ -19,6 +19,8 @@ const TOOLTIPS = {
autoPan: 'Automatically pan to active blocks during workflow execution.',
consoleExpandedByDefault:
'Show console entries expanded by default. When disabled, entries will be collapsed by default.',
floatingControls:
'Show floating controls for zoom, undo, and redo at the bottom of the workflow canvas.',
}
export function General() {
@@ -28,6 +30,7 @@ export function General() {
const isAutoPanEnabled = useGeneralStore((state) => state.isAutoPanEnabled)
const isConsoleExpandedByDefault = useGeneralStore((state) => state.isConsoleExpandedByDefault)
const showFloatingControls = useGeneralStore((state) => state.showFloatingControls)
// Loading states
const isAutoConnectLoading = useGeneralStore((state) => state.isAutoConnectLoading)
@@ -37,6 +40,7 @@ export function General() {
(state) => state.isConsoleExpandedByDefaultLoading
)
const isThemeLoading = useGeneralStore((state) => state.isThemeLoading)
const isFloatingControlsLoading = useGeneralStore((state) => state.isFloatingControlsLoading)
const setTheme = useGeneralStore((state) => state.setTheme)
const toggleAutoConnect = useGeneralStore((state) => state.toggleAutoConnect)
@@ -45,6 +49,7 @@ export function General() {
const toggleConsoleExpandedByDefault = useGeneralStore(
(state) => state.toggleConsoleExpandedByDefault
)
const toggleFloatingControls = useGeneralStore((state) => state.toggleFloatingControls)
// Sync theme from store to next-themes when theme changes
useEffect(() => {
@@ -77,6 +82,12 @@ export function General() {
}
}
const handleFloatingControlsChange = async (checked: boolean) => {
if (checked !== showFloatingControls && !isFloatingControlsLoading) {
await toggleFloatingControls()
}
}
return (
<div className='px-6 pt-4 pb-2'>
<div className='flex flex-col gap-4'>
@@ -241,6 +252,36 @@ export function General() {
disabled={isLoading || isConsoleExpandedByDefaultLoading}
/>
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='floating-controls' className='font-normal'>
Floating controls
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about floating controls'
disabled={isLoading || isFloatingControlsLoading}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.floatingControls}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='floating-controls'
checked={showFloatingControls}
onCheckedChange={handleFloatingControlsChange}
disabled={isLoading || isFloatingControlsLoading}
/>
</div>
</>
)}
</div>
+201 -16
View File
@@ -5,17 +5,63 @@ import { createLogger } from '@/lib/logs/console/logger'
import { getBlock } from '@/blocks'
import { resolveOutputType } from '@/blocks/utils'
import { useSocket } from '@/contexts/socket-context'
import { useUndoRedo } from '@/hooks/use-undo-redo'
import { registerEmitFunctions, useOperationQueue } from '@/stores/operation-queue/store'
import { useVariablesStore } from '@/stores/panel/variables/store'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { Position } from '@/stores/workflows/workflow/types'
import type { BlockState, Position } from '@/stores/workflows/workflow/types'
const logger = createLogger('CollaborativeWorkflow')
export function useCollaborativeWorkflow() {
const undoRedo = useUndoRedo()
const isUndoRedoInProgress = useRef(false)
const skipEdgeRecording = useRef(false)
useEffect(() => {
const moveHandler = (e: any) => {
const { blockId, before, after } = e.detail || {}
if (!blockId || !before || !after) return
// Don't record moves during undo/redo operations
if (isUndoRedoInProgress.current) return
undoRedo.recordMove(blockId, before, after)
}
const parentUpdateHandler = (e: any) => {
const { blockId, oldParentId, newParentId, oldPosition, newPosition, affectedEdges } =
e.detail || {}
if (!blockId) return
// Don't record during undo/redo operations
if (isUndoRedoInProgress.current) return
undoRedo.recordUpdateParent(
blockId,
oldParentId,
newParentId,
oldPosition,
newPosition,
affectedEdges
)
}
const skipEdgeHandler = (e: any) => {
const { skip } = e.detail || {}
skipEdgeRecording.current = skip
}
window.addEventListener('workflow-record-move', moveHandler)
window.addEventListener('workflow-record-parent-update', parentUpdateHandler)
window.addEventListener('skip-edge-recording', skipEdgeHandler)
return () => {
window.removeEventListener('workflow-record-move', moveHandler)
window.removeEventListener('workflow-record-parent-update', parentUpdateHandler)
window.removeEventListener('skip-edge-recording', skipEdgeHandler)
}
}, [undoRedo])
const {
isConnected,
currentWorkflowId,
@@ -125,6 +171,15 @@ export function useCollaborativeWorkflow() {
if (payload.autoConnectEdge) {
workflowStore.addEdge(payload.autoConnectEdge)
}
// Apply subblock values if present in payload
if (payload.subBlocks && typeof payload.subBlocks === 'object') {
Object.entries(payload.subBlocks).forEach(([subblockId, subblock]) => {
const value = (subblock as any)?.value
if (value !== undefined && value !== null) {
subBlockStore.setValue(payload.id, subblockId, value)
}
})
}
break
case 'update-position': {
const blockId = payload.id
@@ -157,11 +212,40 @@ export function useCollaborativeWorkflow() {
case 'update-name':
workflowStore.updateBlockName(payload.id, payload.name)
break
case 'remove':
workflowStore.removeBlock(payload.id)
// Clean up position timestamp tracking for removed blocks
lastPositionTimestamps.current.delete(payload.id)
case 'remove': {
const blockId = payload.id
const blocksToRemove = new Set<string>([blockId])
const findAllDescendants = (parentId: string) => {
Object.entries(workflowStore.blocks).forEach(([id, block]) => {
if (block.data?.parentId === parentId) {
blocksToRemove.add(id)
findAllDescendants(id)
}
})
}
findAllDescendants(blockId)
workflowStore.removeBlock(blockId)
lastPositionTimestamps.current.delete(blockId)
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 [workflowId, userId] = key.split(':')
if (workflowId === activeWorkflowId) {
undoRedoStore.pruneInvalidEntries(workflowId, userId, graph)
}
})
break
}
case 'toggle-enabled':
workflowStore.toggleBlockEnabled(payload.id)
break
@@ -222,9 +306,26 @@ export function useCollaborativeWorkflow() {
case 'add':
workflowStore.addEdge(payload as Edge)
break
case 'remove':
case 'remove': {
workflowStore.removeEdge(payload.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 [workflowId, userId] = key.split(':')
if (workflowId === activeWorkflowId) {
undoRedoStore.pruneInvalidEntries(workflowId, userId, graph)
}
})
break
}
}
} else if (target === 'subflow') {
switch (operation) {
@@ -338,13 +439,14 @@ export function useCollaborativeWorkflow() {
const { workflowId } = data
logger.warn(`Workflow ${workflowId} has been deleted`)
// If the deleted workflow is the currently active one, we need to handle this gracefully
if (activeWorkflowId === workflowId) {
logger.info(
`Currently active workflow ${workflowId} was deleted, stopping collaborative operations`
)
// The workflow registry should handle switching to another workflow
// We just need to stop any pending collaborative operations
const currentUserId = session?.user?.id || 'unknown'
useUndoRedoStore.getState().clear(workflowId, currentUserId)
isApplyingRemoteChange.current = false
}
}
@@ -400,6 +502,22 @@ export function useCollaborativeWorkflow() {
}))
logger.info(`Successfully loaded reverted workflow state for ${workflowId}`)
const graph = {
blocksById: workflowData.state.blocks || {},
edgesById: Object.fromEntries(
(workflowData.state.edges || []).map((e: any) => [e.id, e])
),
}
const undoRedoStore = useUndoRedoStore.getState()
const stackKeys = Object.keys(undoRedoStore.stacks)
stackKeys.forEach((key) => {
const [wfId, userId] = key.split(':')
if (wfId === workflowId) {
undoRedoStore.pruneInvalidEntries(wfId, userId, graph)
}
})
} finally {
isApplyingRemoteChange.current = false
}
@@ -617,6 +735,9 @@ export function useCollaborativeWorkflow() {
workflowStore.addEdge(autoConnectEdge)
}
// Record for undo AFTER adding (pass the autoConnectEdge explicitly)
undoRedo.recordAddBlock(id, autoConnectEdge)
return
}
@@ -685,6 +806,9 @@ export function useCollaborativeWorkflow() {
if (autoConnectEdge) {
workflowStore.addEdge(autoConnectEdge)
}
// Record for undo AFTER adding (pass the autoConnectEdge explicitly)
undoRedo.recordAddBlock(id, autoConnectEdge)
},
[
workflowStore,
@@ -694,6 +818,7 @@ export function useCollaborativeWorkflow() {
isShowingDiff,
isInActiveRoom,
currentWorkflowId,
undoRedo,
]
)
@@ -701,13 +826,44 @@ export function useCollaborativeWorkflow() {
(id: string) => {
cancelOperationsForBlock(id)
// Get all blocks that will be removed (including nested blocks in subflows)
const blocksToRemove = new Set<string>([id])
const findAllDescendants = (parentId: string) => {
Object.entries(workflowStore.blocks).forEach(([blockId, block]) => {
if (block.data?.parentId === parentId) {
blocksToRemove.add(blockId)
findAllDescendants(blockId)
}
})
}
findAllDescendants(id)
// Capture state before removal, including all nested blocks with subblock values
const allBlocks = mergeSubblockState(workflowStore.blocks, activeWorkflowId || undefined)
const capturedBlocks: Record<string, BlockState> = {}
blocksToRemove.forEach((blockId) => {
if (allBlocks[blockId]) {
capturedBlocks[blockId] = allBlocks[blockId]
}
})
// Capture all edges connected to any of the blocks being removed
const edges = workflowStore.edges.filter(
(edge) => blocksToRemove.has(edge.source) || blocksToRemove.has(edge.target)
)
if (Object.keys(capturedBlocks).length > 0) {
undoRedo.recordRemoveBlock(id, capturedBlocks[id], edges, capturedBlocks)
}
executeQueuedOperation('remove', 'block', { id }, () => workflowStore.removeBlock(id))
},
[executeQueuedOperation, workflowStore, cancelOperationsForBlock]
[executeQueuedOperation, workflowStore, cancelOperationsForBlock, undoRedo, activeWorkflowId]
)
const collaborativeUpdateBlockPosition = useCallback(
(id: string, position: Position) => {
// Only apply position updates here (no undo recording to avoid micro-moves)
executeQueuedDebouncedOperation('update-position', 'block', { id, position }, () =>
workflowStore.updateBlockPosition(id, position)
)
@@ -812,17 +968,27 @@ export function useCollaborativeWorkflow() {
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)
}
},
[executeQueuedOperation, workflowStore]
[executeQueuedOperation, workflowStore, undoRedo]
)
const collaborativeRemoveEdge = useCallback(
(edgeId: string) => {
const edge = workflowStore.edges.find((e) => e.id === edgeId)
// Only record edge removal if it's not part of a parent update operation
if (edge && !skipEdgeRecording.current) {
undoRedo.recordRemoveEdge(edgeId, edge)
}
executeQueuedOperation('remove', 'edge', { id: edgeId }, () =>
workflowStore.removeEdge(edgeId)
)
},
[executeQueuedOperation, workflowStore]
[executeQueuedOperation, workflowStore, undoRedo]
)
const collaborativeSetSubblockValue = useCallback(
@@ -960,10 +1126,7 @@ export function useCollaborativeWorkflow() {
y: sourceBlock.position.y + 20,
}
const match = sourceBlock.name.match(/(.*?)(\d+)?$/)
const newName = match?.[2]
? `${match[1]}${Number.parseInt(match[2]) + 1}`
: `${sourceBlock.name} 1`
const newName = getUniqueBlockName(sourceBlock.name, workflowStore.blocks)
// Get subblock values from the store
const subBlockValues = subBlockStore.workflowValues[activeWorkflowId || '']?.[sourceId] || {}
@@ -1049,6 +1212,9 @@ export function useCollaborativeWorkflow() {
subBlockStore.setValue(newId, subblockId, value)
})
}
// Record for undo after the block is added
undoRedo.recordDuplicateBlock(sourceId, newId, duplicatedBlockData, undefined)
})
},
[
@@ -1058,6 +1224,7 @@ export function useCollaborativeWorkflow() {
activeWorkflowId,
isInActiveRoom,
currentWorkflowId,
undoRedo,
]
)
@@ -1329,5 +1496,23 @@ export function useCollaborativeWorkflow() {
// Direct access to stores for non-collaborative operations
workflowStore,
subBlockStore,
// Undo/Redo operations (wrapped to prevent recording moves during undo/redo)
undo: useCallback(() => {
isUndoRedoInProgress.current = true
undoRedo.undo()
queueMicrotask(() => {
isUndoRedoInProgress.current = false
})
}, [undoRedo]),
redo: useCallback(() => {
isUndoRedoInProgress.current = true
undoRedo.redo()
queueMicrotask(() => {
isUndoRedoInProgress.current = false
})
}, [undoRedo]),
getUndoRedoSizes: undoRedo.getStackSizes,
clearUndoRedo: undoRedo.clearStacks,
}
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -22,6 +22,7 @@ export const useGeneralStore = create<GeneralStore>()(
isAutoPanEnabled: true,
isConsoleExpandedByDefault: true,
isDebugModeEnabled: false,
showFloatingControls: true,
theme: 'system' as const, // Keep for compatibility but not used
telemetryEnabled: true,
isLoading: false,
@@ -34,6 +35,7 @@ export const useGeneralStore = create<GeneralStore>()(
isTelemetryLoading: false,
isBillingUsageNotificationsLoading: false,
isBillingUsageNotificationsEnabled: true,
isFloatingControlsLoading: false,
}
// Optimistic update helper
@@ -101,6 +103,17 @@ export const useGeneralStore = create<GeneralStore>()(
set({ isDebugModeEnabled: !get().isDebugModeEnabled })
},
toggleFloatingControls: async () => {
if (get().isFloatingControlsLoading) return
const newValue = !get().showFloatingControls
await updateSettingOptimistic(
'showFloatingControls',
newValue,
'isFloatingControlsLoading',
'showFloatingControls'
)
},
setTheme: async (theme) => {
if (get().isThemeLoading) return
@@ -203,6 +216,7 @@ export const useGeneralStore = create<GeneralStore>()(
isAutoConnectEnabled: data.autoConnect,
isAutoPanEnabled: data.autoPan ?? true,
isConsoleExpandedByDefault: data.consoleExpandedByDefault ?? true,
showFloatingControls: data.showFloatingControls ?? true,
theme: data.theme || 'system',
telemetryEnabled: data.telemetryEnabled,
isBillingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
+4 -1
View File
@@ -3,6 +3,7 @@ export interface General {
isAutoPanEnabled: boolean
isConsoleExpandedByDefault: boolean
isDebugModeEnabled: boolean
showFloatingControls: boolean
theme: 'system' | 'light' | 'dark'
telemetryEnabled: boolean
isLoading: boolean
@@ -14,14 +15,15 @@ export interface General {
isTelemetryLoading: boolean
isBillingUsageNotificationsLoading: boolean
isBillingUsageNotificationsEnabled: boolean
isFloatingControlsLoading: boolean
}
export interface GeneralActions {
toggleAutoConnect: () => Promise<void>
toggleAutoPan: () => Promise<void>
toggleConsoleExpandedByDefault: () => Promise<void>
toggleDebugMode: () => void
toggleFloatingControls: () => Promise<void>
setTheme: (theme: 'system' | 'light' | 'dark') => Promise<void>
setTelemetryEnabled: (enabled: boolean) => Promise<void>
setBillingUsageNotificationsEnabled: (enabled: boolean) => Promise<void>
@@ -36,6 +38,7 @@ export type UserSettings = {
autoConnect: boolean
autoPan: boolean
consoleExpandedByDefault: boolean
showFloatingControls: boolean
telemetryEnabled: boolean
isBillingUsageNotificationsEnabled: boolean
}
+3
View File
@@ -0,0 +1,3 @@
export { useUndoRedoStore } from './store'
export * from './types'
export * from './utils'
+356
View File
@@ -0,0 +1,356 @@
import type { Edge } from 'reactflow'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type {
MoveBlockOperation,
Operation,
OperationEntry,
RemoveBlockOperation,
RemoveEdgeOperation,
UndoRedoState,
} from './types'
const logger = createLogger('UndoRedoStore')
const DEFAULT_CAPACITY = 15
function getStackKey(workflowId: string, userId: string): string {
return `${workflowId}:${userId}`
}
function isOperationApplicable(
operation: Operation,
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
): boolean {
switch (operation.type) {
case 'remove-block': {
const op = operation as RemoveBlockOperation
return Boolean(graph.blocksById[op.data.blockId])
}
case 'add-block': {
const blockId = operation.data.blockId
return !graph.blocksById[blockId]
}
case 'move-block': {
const op = operation as MoveBlockOperation
return Boolean(graph.blocksById[op.data.blockId])
}
case 'update-parent': {
const blockId = operation.data.blockId
return Boolean(graph.blocksById[blockId])
}
case 'duplicate-block': {
const duplicatedId = operation.data.duplicatedBlockId
return Boolean(graph.blocksById[duplicatedId])
}
case 'remove-edge': {
const op = operation as RemoveEdgeOperation
return Boolean(graph.edgesById[op.data.edgeId])
}
case 'add-edge': {
const edgeId = operation.data.edgeId
return !graph.edgesById[edgeId]
}
case 'add-subflow':
case 'remove-subflow': {
const subflowId = operation.data.subflowId
return operation.type === 'remove-subflow'
? Boolean(graph.blocksById[subflowId])
: !graph.blocksById[subflowId]
}
default:
return true
}
}
export const useUndoRedoStore = create<UndoRedoState>()(
persist(
(set, get) => ({
stacks: {},
capacity: DEFAULT_CAPACITY,
push: (workflowId: string, userId: string, entry: OperationEntry) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key] || { undo: [], redo: [] }
// Coalesce consecutive move-block operations for the same block
if (entry.operation.type === 'move-block') {
const incoming = entry.operation as MoveBlockOperation
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')
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
const sameAfter =
mergedBefore.x === mergedAfter.x &&
mergedBefore.y === mergedAfter.y &&
(mergedBefore.parentId ?? null) === (mergedAfter.parentId ?? null)
const newUndoCoalesced: OperationEntry[] = sameAfter
? stack.undo.slice(0, -1)
: (() => {
const op = entry.operation as MoveBlockOperation
const inv = entry.inverse as MoveBlockOperation
const newEntry: OperationEntry = {
id: entry.id,
createdAt: entry.createdAt,
operation: {
id: op.id,
type: 'move-block',
timestamp: op.timestamp,
workflowId,
userId,
data: {
blockId: incoming.data.blockId,
before: mergedBefore,
after: mergedAfter,
},
},
inverse: {
id: inv.id,
type: 'move-block',
timestamp: inv.timestamp,
workflowId,
userId,
data: {
blockId: incoming.data.blockId,
before: mergedAfter,
after: mergedBefore,
},
},
}
return [...stack.undo.slice(0, -1), newEntry]
})()
set({
stacks: {
...state.stacks,
[key]: { undo: newUndoCoalesced, redo: [] },
},
})
logger.debug('Coalesced consecutive move operations', {
workflowId,
userId,
blockId: incoming.data.blockId,
undoSize: newUndoCoalesced.length,
})
return
}
}
}
const newUndo = [...stack.undo, entry]
if (newUndo.length > state.capacity) {
newUndo.shift()
}
set({
stacks: {
...state.stacks,
[key]: { undo: newUndo, redo: [] },
},
})
logger.debug('Pushed operation to undo stack', {
workflowId,
userId,
operationType: entry.operation.type,
undoSize: newUndo.length,
})
},
undo: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack || stack.undo.length === 0) {
return null
}
const entry = stack.undo[stack.undo.length - 1]
const newUndo = stack.undo.slice(0, -1)
const newRedo = [...stack.redo, entry]
if (newRedo.length > state.capacity) {
newRedo.shift()
}
set({
stacks: {
...state.stacks,
[key]: { undo: newUndo, redo: newRedo },
},
})
logger.debug('Undo operation', {
workflowId,
userId,
operationType: entry.operation.type,
undoSize: newUndo.length,
redoSize: newRedo.length,
})
return entry
},
redo: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack || stack.redo.length === 0) {
return null
}
const entry = stack.redo[stack.redo.length - 1]
const newRedo = stack.redo.slice(0, -1)
const newUndo = [...stack.undo, entry]
if (newUndo.length > state.capacity) {
newUndo.shift()
}
set({
stacks: {
...state.stacks,
[key]: { undo: newUndo, redo: newRedo },
},
})
logger.debug('Redo operation', {
workflowId,
userId,
operationType: entry.operation.type,
undoSize: newUndo.length,
redoSize: newRedo.length,
})
return entry
},
clear: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const { [key]: _, ...rest } = state.stacks
set({ stacks: rest })
logger.debug('Cleared undo/redo stacks', { workflowId, userId })
},
clearRedo: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack) return
set({
stacks: {
...state.stacks,
[key]: { ...stack, redo: [] },
},
})
logger.debug('Cleared redo stack', { workflowId, userId })
},
getStackSizes: (workflowId: string, userId: string) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack) {
return { undoSize: 0, redoSize: 0 }
}
return {
undoSize: stack.undo.length,
redoSize: stack.redo.length,
}
},
setCapacity: (capacity: number) => {
const state = get()
const newStacks: typeof state.stacks = {}
for (const [key, stack] of Object.entries(state.stacks)) {
newStacks[key] = {
undo: stack.undo.slice(-capacity),
redo: stack.redo.slice(-capacity),
}
}
set({ capacity, stacks: newStacks })
logger.debug('Set capacity', { capacity })
},
pruneInvalidEntries: (
workflowId: string,
userId: string,
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
) => {
const key = getStackKey(workflowId, userId)
const state = get()
const stack = state.stacks[key]
if (!stack) return
const originalUndoCount = stack.undo.length
const originalRedoCount = stack.redo.length
const validUndo = stack.undo.filter((entry) => isOperationApplicable(entry.inverse, graph))
const validRedo = stack.redo.filter((entry) =>
isOperationApplicable(entry.operation, graph)
)
const prunedUndoCount = originalUndoCount - validUndo.length
const prunedRedoCount = originalRedoCount - validRedo.length
if (prunedUndoCount > 0 || prunedRedoCount > 0) {
set({
stacks: {
...state.stacks,
[key]: { undo: validUndo, redo: validRedo },
},
})
logger.debug('Pruned invalid entries', {
workflowId,
userId,
prunedUndo: prunedUndoCount,
prunedRedo: prunedRedoCount,
remainingUndo: validUndo.length,
remainingRedo: validRedo.length,
})
}
},
}),
{
name: 'workflow-undo-redo',
partialize: (state) => ({
stacks: state.stacks,
capacity: state.capacity,
}),
}
)
)
+165
View File
@@ -0,0 +1,165 @@
import type { Edge } from 'reactflow'
import type { BlockState } from '@/stores/workflows/workflow/types'
export type OperationType =
| 'add-block'
| 'remove-block'
| 'add-edge'
| 'remove-edge'
| 'add-subflow'
| 'remove-subflow'
| 'move-block'
| 'move-subflow'
| 'duplicate-block'
| 'update-parent'
export interface BaseOperation {
id: string
type: OperationType
timestamp: number
workflowId: string
userId: string
}
export interface AddBlockOperation extends BaseOperation {
type: 'add-block'
data: {
blockId: string
}
}
export interface RemoveBlockOperation extends BaseOperation {
type: 'remove-block'
data: {
blockId: string
blockSnapshot: BlockState | null
edgeSnapshots?: Edge[]
allBlockSnapshots?: Record<string, BlockState>
}
}
export interface AddEdgeOperation extends BaseOperation {
type: 'add-edge'
data: {
edgeId: string
}
}
export interface RemoveEdgeOperation extends BaseOperation {
type: 'remove-edge'
data: {
edgeId: string
edgeSnapshot: Edge | null
}
}
export interface AddSubflowOperation extends BaseOperation {
type: 'add-subflow'
data: {
subflowId: string
}
}
export interface RemoveSubflowOperation extends BaseOperation {
type: 'remove-subflow'
data: {
subflowId: string
subflowSnapshot: BlockState | null
}
}
export interface MoveBlockOperation extends BaseOperation {
type: 'move-block'
data: {
blockId: string
before: {
x: number
y: number
parentId?: string
}
after: {
x: number
y: number
parentId?: string
}
}
}
export interface MoveSubflowOperation extends BaseOperation {
type: 'move-subflow'
data: {
subflowId: string
before: {
x: number
y: number
}
after: {
x: number
y: number
}
}
}
export interface DuplicateBlockOperation extends BaseOperation {
type: 'duplicate-block'
data: {
sourceBlockId: string
duplicatedBlockId: string
duplicatedBlockSnapshot: BlockState
autoConnectEdge?: Edge
}
}
export interface UpdateParentOperation extends BaseOperation {
type: 'update-parent'
data: {
blockId: string
oldParentId?: string
newParentId?: string
oldPosition: { x: number; y: number }
newPosition: { x: number; y: number }
affectedEdges?: Edge[]
}
}
export type Operation =
| AddBlockOperation
| RemoveBlockOperation
| AddEdgeOperation
| RemoveEdgeOperation
| AddSubflowOperation
| RemoveSubflowOperation
| MoveBlockOperation
| MoveSubflowOperation
| DuplicateBlockOperation
| UpdateParentOperation
export interface OperationEntry {
id: string
operation: Operation
inverse: Operation
createdAt: number
}
export interface UndoRedoState {
stacks: Record<
string,
{
undo: OperationEntry[]
redo: OperationEntry[]
}
>
capacity: number
push: (workflowId: string, userId: string, entry: OperationEntry) => void
undo: (workflowId: string, userId: string) => OperationEntry | null
redo: (workflowId: string, userId: string) => OperationEntry | null
clear: (workflowId: string, userId: string) => void
clearRedo: (workflowId: string, userId: string) => void
getStackSizes: (workflowId: string, userId: string) => { undoSize: number; redoSize: number }
setCapacity: (capacity: number) => void
pruneInvalidEntries: (
workflowId: string,
userId: string,
graph: { blocksById: Record<string, BlockState>; edgesById: Record<string, Edge> }
) => void
}
+221
View File
@@ -0,0 +1,221 @@
import type { Operation, OperationEntry } from './types'
export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry {
return {
id: crypto.randomUUID(),
operation,
inverse,
createdAt: Date.now(),
}
}
export function createInverseOperation(operation: Operation): Operation {
switch (operation.type) {
case 'add-block':
return {
...operation,
type: 'remove-block',
data: {
blockId: operation.data.blockId,
blockSnapshot: null,
edgeSnapshots: [],
},
}
case 'remove-block':
return {
...operation,
type: 'add-block',
data: {
blockId: operation.data.blockId,
},
}
case 'add-edge':
return {
...operation,
type: 'remove-edge',
data: {
edgeId: operation.data.edgeId,
edgeSnapshot: null,
},
}
case 'remove-edge':
return {
...operation,
type: 'add-edge',
data: {
edgeId: operation.data.edgeId,
},
}
case 'add-subflow':
return {
...operation,
type: 'remove-subflow',
data: {
subflowId: operation.data.subflowId,
subflowSnapshot: null,
},
}
case 'remove-subflow':
return {
...operation,
type: 'add-subflow',
data: {
subflowId: operation.data.subflowId,
},
}
case 'move-block':
return {
...operation,
data: {
blockId: operation.data.blockId,
before: operation.data.after,
after: operation.data.before,
},
}
case 'move-subflow':
return {
...operation,
data: {
subflowId: operation.data.subflowId,
before: operation.data.after,
after: operation.data.before,
},
}
case 'duplicate-block':
return {
...operation,
type: 'remove-block',
data: {
blockId: operation.data.duplicatedBlockId,
blockSnapshot: operation.data.duplicatedBlockSnapshot,
edgeSnapshots: [],
},
}
case 'update-parent':
return {
...operation,
data: {
blockId: operation.data.blockId,
oldParentId: operation.data.newParentId,
newParentId: operation.data.oldParentId,
oldPosition: operation.data.newPosition,
newPosition: operation.data.oldPosition,
affectedEdges: operation.data.affectedEdges,
},
}
default: {
const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as any).type}`)
}
}
}
export function operationToCollaborativePayload(operation: Operation): {
operation: string
target: string
payload: any
} {
switch (operation.type) {
case 'add-block':
return {
operation: 'add',
target: 'block',
payload: { id: operation.data.blockId },
}
case 'remove-block':
return {
operation: 'remove',
target: 'block',
payload: { id: operation.data.blockId },
}
case 'add-edge':
return {
operation: 'add',
target: 'edge',
payload: { id: operation.data.edgeId },
}
case 'remove-edge':
return {
operation: 'remove',
target: 'edge',
payload: { id: operation.data.edgeId },
}
case 'add-subflow':
return {
operation: 'add',
target: 'subflow',
payload: { id: operation.data.subflowId },
}
case 'remove-subflow':
return {
operation: 'remove',
target: 'subflow',
payload: { id: operation.data.subflowId },
}
case 'move-block':
return {
operation: 'update-position',
target: 'block',
payload: {
id: operation.data.blockId,
x: operation.data.after.x,
y: operation.data.after.y,
parentId: operation.data.after.parentId,
},
}
case 'move-subflow':
return {
operation: 'update-position',
target: 'subflow',
payload: {
id: operation.data.subflowId,
x: operation.data.after.x,
y: operation.data.after.y,
},
}
case 'duplicate-block':
return {
operation: 'duplicate',
target: 'block',
payload: {
sourceId: operation.data.sourceBlockId,
duplicatedId: operation.data.duplicatedBlockId,
},
}
case 'update-parent':
return {
operation: 'update-parent',
target: 'block',
payload: {
id: operation.data.blockId,
parentId: operation.data.newParentId,
x: operation.data.newPosition.x,
y: operation.data.newPosition.y,
},
}
default: {
const exhaustiveCheck: never = operation
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as any).type}`)
}
}
}
+42
View File
@@ -1,6 +1,48 @@
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
/**
* Normalizes a block name for comparison by converting to lowercase and removing spaces
* @param name - The block name to normalize
* @returns The normalized name
*/
export function normalizeBlockName(name: string): string {
return name.toLowerCase().replace(/\s+/g, '')
}
/**
* Generates a unique block name by finding the highest number suffix among existing blocks
* with the same base name and incrementing it
* @param baseName - The base name for the block (e.g., "API 1", "Agent", "Loop 3")
* @param existingBlocks - Record of existing blocks to check against
* @returns A unique block name with an appropriate number suffix
*/
export function getUniqueBlockName(baseName: string, existingBlocks: Record<string, any>): string {
const baseNameMatch = baseName.match(/^(.*?)(\s+\d+)?$/)
const namePrefix = baseNameMatch ? baseNameMatch[1].trim() : baseName
const normalizedBase = normalizeBlockName(namePrefix)
const existingNumbers = Object.values(existingBlocks)
.filter((block) => {
const blockNameMatch = block.name?.match(/^(.*?)(\s+\d+)?$/)
const blockPrefix = blockNameMatch ? blockNameMatch[1].trim() : block.name
return blockPrefix && normalizeBlockName(blockPrefix) === normalizedBase
})
.map((block) => {
const match = block.name?.match(/(\d+)$/)
return match ? Number.parseInt(match[1], 10) : 0
})
const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0
if (maxNumber === 0 && existingNumbers.length === 0) {
return `${namePrefix} 1`
}
return `${namePrefix} ${maxNumber + 1}`
}
/**
* Merges workflow block states with subblock values while maintaining block structure
* @param blocks - Block configurations from workflow store
+14 -11
View File
@@ -11,7 +11,11 @@ import {
} from '@/stores/workflows/middleware'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import {
getUniqueBlockName,
mergeSubblockState,
normalizeBlockName,
} from '@/stores/workflows/utils'
import type {
Position,
SubBlockState,
@@ -521,11 +525,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
y: block.position.y + 20,
}
// More efficient name handling
const match = block.name.match(/(.*?)(\d+)?$/)
const newName = match?.[2]
? `${match[1]}${Number.parseInt(match[2]) + 1}`
: `${block.name} 1`
const newName = getUniqueBlockName(block.name, get().blocks)
// Get merged state to capture current subblock values
const mergedBlock = mergeSubblockState(get().blocks, id)[id]
@@ -602,11 +602,6 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
const oldBlock = get().blocks[id]
if (!oldBlock) return false
// Helper function to normalize block names (same as resolver)
const normalizeBlockName = (blockName: string): string => {
return blockName.toLowerCase().replace(/\s+/g, '')
}
// Check for normalized name collisions
const normalizedNewName = normalizeBlockName(name)
const currentBlocks = get().blocks
@@ -1173,6 +1168,14 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
generateParallelBlocks: () => {
return generateParallelBlocks(get().blocks)
},
setDragStartPosition: (position) => {
set({ dragStartPosition: position })
},
getDragStartPosition: () => {
return get().dragStartPosition || null
},
})),
{ name: 'workflow-store' }
)
@@ -128,6 +128,13 @@ export interface Parallel {
parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs
}
export interface DragStartPosition {
id: string
x: number
y: number
parentId?: string | null
}
export interface WorkflowState {
blocks: Record<string, BlockState>
edges: Edge[]
@@ -142,6 +149,8 @@ export interface WorkflowState {
deploymentStatuses?: Record<string, DeploymentStatus>
needsRedeployment?: boolean
hasActiveWebhook?: boolean
// Drag state for undo/redo
dragStartPosition?: DragStartPosition | null
}
// New interface for sync control
@@ -203,6 +212,8 @@ export interface WorkflowActions {
revertToDeployedState: (deployedState: WorkflowState) => void
toggleBlockAdvancedMode: (id: string) => void
toggleBlockTriggerMode: (id: string) => void
setDragStartPosition: (position: DragStartPosition | null) => void
getDragStartPosition: () => DragStartPosition | null
// Add the sync control methods to the WorkflowActions interface
sync: SyncControl