Compare commits

...

5 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -264,12 +264,14 @@ const WorkflowContent = React.memo(() => {
const canUndo = undoRedoStack.undo.length > 0 const canUndo = undoRedoStack.undo.length > 0
const canRedo = undoRedoStack.redo.length > 0 const canRedo = undoRedoStack.redo.length > 0
const { updateNodeDimensions, setDragStartPosition, getDragStartPosition } = useWorkflowStore( const { updateNodeDimensions, setDragStartPosition, getDragStartPosition, getGroups } =
useShallow((state) => ({ useWorkflowStore(
updateNodeDimensions: state.updateNodeDimensions, useShallow((state) => ({
setDragStartPosition: state.setDragStartPosition, updateNodeDimensions: state.updateNodeDimensions,
getDragStartPosition: state.getDragStartPosition, setDragStartPosition: state.setDragStartPosition,
})) getDragStartPosition: state.getDragStartPosition,
getGroups: state.getGroups,
}))
) )
const copilotCleanup = useCopilotStore((state) => state.cleanup) const copilotCleanup = useCopilotStore((state) => state.cleanup)
@@ -361,6 +363,14 @@ const WorkflowContent = React.memo(() => {
new Map() new Map()
) )
/**
* Stores original positions and parentIds for nodes temporarily parented during group drag.
* Key: node ID, Value: { originalPosition, originalParentId }
*/
const groupDragTempParentsRef = useRef<
Map<string, { originalPosition: { x: number; y: number }; originalParentId?: string }>
>(new Map())
/** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */ /** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */
const pendingSelectionRef = useRef<Set<string> | null>(null) const pendingSelectionRef = useRef<Set<string> | null>(null)
@@ -458,6 +468,8 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchRemoveBlocks, collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled, collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles, collaborativeBatchToggleBlockHandles,
collaborativeGroupBlocks,
collaborativeUngroupBlocks,
undo, undo,
redo, redo,
} = useCollaborativeWorkflow() } = useCollaborativeWorkflow()
@@ -782,6 +794,35 @@ const WorkflowContent = React.memo(() => {
collaborativeBatchToggleBlockHandles(blockIds) collaborativeBatchToggleBlockHandles(blockIds)
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles]) }, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
const handleContextGroupBlocks = useCallback(() => {
const blockIds = contextMenuBlocks.map((block) => block.id)
if (blockIds.length >= 2) {
// Validate that all blocks share the same parent (or all have no parent)
// Blocks inside a subflow cannot be grouped with blocks outside that subflow
const parentIds = contextMenuBlocks.map((block) => block.parentId || null)
const uniqueParentIds = new Set(parentIds)
if (uniqueParentIds.size > 1) {
addNotification({
level: 'error',
message: 'Cannot group blocks from different subflows',
})
return
}
collaborativeGroupBlocks(blockIds)
}
}, [contextMenuBlocks, collaborativeGroupBlocks, addNotification])
const handleContextUngroupBlocks = useCallback(() => {
// Find the first block with a groupId
const groupedBlock = contextMenuBlocks.find((block) => block.groupId)
if (!groupedBlock?.groupId) return
// The block's groupId is the group we want to ungroup
// This is the direct group the block belongs to, which is the "top level" from the user's perspective
// (the most recently created group that contains this block)
collaborativeUngroupBlocks(groupedBlock.groupId)
}, [contextMenuBlocks, collaborativeUngroupBlocks])
const handleContextRemoveFromSubflow = useCallback(() => { const handleContextRemoveFromSubflow = useCallback(() => {
const blocksToRemove = contextMenuBlocks.filter( const blocksToRemove = contextMenuBlocks.filter(
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel') (block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
@@ -1906,6 +1947,7 @@ const WorkflowContent = React.memo(() => {
name: block.name, name: block.name,
isActive, isActive,
isPending, isPending,
groupId: block.data?.groupId,
}, },
// Include dynamic dimensions for container resizing calculations (must match rendered size) // Include dynamic dimensions for container resizing calculations (must match rendered size)
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions // Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
@@ -2060,16 +2102,56 @@ const WorkflowContent = React.memo(() => {
window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener)
}, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent]) }, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent])
/** Handles node changes - applies changes and resolves parent-child selection conflicts. */ /** Handles node changes - applies changes and resolves parent-child selection conflicts.
* Also expands selection to include all group members when a grouped block is selected.
*/
const onNodesChange = useCallback( const onNodesChange = useCallback(
(changes: NodeChange[]) => { (changes: NodeChange[]) => {
setDisplayNodes((nds) => { setDisplayNodes((nds) => {
const updated = applyNodeChanges(changes, nds) let updated = applyNodeChanges(changes, nds)
const hasSelectionChange = changes.some((c) => c.type === 'select') const hasSelectionChange = changes.some((c) => c.type === 'select')
return hasSelectionChange ? resolveParentChildSelectionConflicts(updated, blocks) : updated
if (hasSelectionChange) {
// Expand selection to include all group members
const groups = getGroups()
const selectedNodeIds = new Set(updated.filter((n) => n.selected).map((n) => n.id))
const groupsToInclude = new Set<string>()
// Find all groups that have at least one selected member
selectedNodeIds.forEach((nodeId) => {
const groupId = blocks[nodeId]?.data?.groupId
if (groupId && groups[groupId]) {
groupsToInclude.add(groupId)
}
})
// Add all blocks from those groups to the selection
if (groupsToInclude.size > 0) {
const expandedNodeIds = new Set(selectedNodeIds)
groupsToInclude.forEach((groupId) => {
const group = groups[groupId]
if (group) {
group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId))
}
})
// Update nodes to include expanded selection
if (expandedNodeIds.size > selectedNodeIds.size) {
updated = updated.map((n) => ({
...n,
selected: expandedNodeIds.has(n.id) ? true : n.selected,
}))
}
}
// Resolve parent-child conflicts
updated = resolveParentChildSelectionConflicts(updated, blocks)
}
return updated
}) })
}, },
[blocks] [blocks, getGroups]
) )
/** /**
@@ -2530,9 +2612,55 @@ const WorkflowContent = React.memo(() => {
parentId: currentParentId, parentId: currentParentId,
}) })
// Capture all selected nodes' positions for multi-node undo/redo // Expand selection to include all group members before capturing positions
const groups = getGroups()
const allNodes = getNodes() const allNodes = getNodes()
const selectedNodes = allNodes.filter((n) => n.selected)
// Find the group of the dragged node
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
// If the dragged node is in a group, expand selection to include all group members
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
const group = groups[draggedBlockGroupId]
const groupBlockIds = new Set(group.blockIds)
// Check if we need to expand selection
const currentSelectedIds = new Set(allNodes.filter((n) => n.selected).map((n) => n.id))
const needsExpansion = [...groupBlockIds].some((id) => !currentSelectedIds.has(id))
if (needsExpansion) {
setNodes((nodes) =>
nodes.map((n) => {
const isInGroup = groupBlockIds.has(n.id)
const isDirectlyDragged = n.id === node.id
return {
...n,
selected: isInGroup ? true : n.selected,
data: {
...n.data,
// Mark as grouped selection if in group but not the directly dragged node
isGroupedSelection: isInGroup && !isDirectlyDragged && !n.selected,
},
}
})
)
}
}
// Capture all selected nodes' positions for multi-node undo/redo
// Re-get nodes after potential selection expansion
const updatedNodes = getNodes()
const selectedNodes = updatedNodes.filter((n) => {
// Always include the dragged node
if (n.id === node.id) return true
// Include node if it's selected OR if it's in the same group as the dragged node
if (n.selected) return true
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
return groups[draggedBlockGroupId].blockIds.includes(n.id)
}
return false
})
multiNodeDragStartRef.current.clear() multiNodeDragStartRef.current.clear()
selectedNodes.forEach((n) => { selectedNodes.forEach((n) => {
const block = blocks[n.id] const block = blocks[n.id]
@@ -2544,8 +2672,63 @@ const WorkflowContent = React.memo(() => {
}) })
} }
}) })
// Set up temporary parent-child relationships for group members
// This leverages React Flow's built-in parent-child drag behavior
// BUT: Only do this if NOT all group members are already selected
// If all are selected, React Flow's native multiselect drag will handle it
groupDragTempParentsRef.current.clear()
if (draggedBlockGroupId && groups[draggedBlockGroupId]) {
const group = groups[draggedBlockGroupId]
if (group.blockIds.length > 1) {
// Check if all group members are already selected
const allGroupMembersSelected = group.blockIds.every((blockId) =>
updatedNodes.find((n) => n.id === blockId && n.selected)
)
// Only use temporary parent approach if NOT all members are selected
// (i.e., when click-and-dragging on an unselected grouped block)
if (!allGroupMembersSelected) {
// Get the dragged node's absolute position for calculating relative positions
const draggedNodeAbsPos = getNodeAbsolutePosition(node.id)
setNodes((nodes) =>
nodes.map((n) => {
// Skip the dragged node - it becomes the temporary parent
if (n.id === node.id) return n
// Only process nodes in the same group
if (group.blockIds.includes(n.id)) {
// Store original position and parentId for restoration later
groupDragTempParentsRef.current.set(n.id, {
originalPosition: { ...n.position },
originalParentId: n.parentId,
})
// Get this node's absolute position
const nodeAbsPos = getNodeAbsolutePosition(n.id)
// Calculate position relative to the dragged node
const relativePosition = {
x: nodeAbsPos.x - draggedNodeAbsPos.x,
y: nodeAbsPos.y - draggedNodeAbsPos.y,
}
return {
...n,
parentId: node.id, // Temporarily make this a child of the dragged node
position: relativePosition,
extent: undefined, // Remove extent constraint during drag
}
}
return n
})
)
}
}
}
}, },
[blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId] [blocks, setDragStartPosition, getNodes, setNodes, getGroups, potentialParentId, setPotentialParentId, getNodeAbsolutePosition]
) )
/** Handles node drag stop to establish parent-child relationships. */ /** Handles node drag stop to establish parent-child relationships. */
@@ -2553,13 +2736,93 @@ const WorkflowContent = React.memo(() => {
(_event: React.MouseEvent, node: any) => { (_event: React.MouseEvent, node: any) => {
clearDragHighlights() clearDragHighlights()
// Compute absolute positions for group members before restoring parentIds
// We need to do this first because getNodes() will return stale data after setNodes
const computedGroupPositions = new Map<string, { x: number; y: number }>()
const draggedBlockGroupId = blocks[node.id]?.data?.groupId
if (groupDragTempParentsRef.current.size > 0) {
const draggedNodeAbsPos = getNodeAbsolutePosition(node.id)
const currentNodes = getNodes()
// Compute absolute positions for all temporarily parented nodes
for (const [nodeId, _tempData] of groupDragTempParentsRef.current) {
const nodeData = currentNodes.find((n) => n.id === nodeId)
if (nodeData) {
// The node's current position is relative to the dragged node
computedGroupPositions.set(nodeId, {
x: draggedNodeAbsPos.x + nodeData.position.x,
y: draggedNodeAbsPos.y + nodeData.position.y,
})
}
}
// Also store the dragged node's absolute position
computedGroupPositions.set(node.id, draggedNodeAbsPos)
// Restore temporary parent-child relationships
setNodes((nodes) =>
nodes.map((n) => {
const tempData = groupDragTempParentsRef.current.get(n.id)
if (tempData) {
const absolutePosition = computedGroupPositions.get(n.id) || n.position
return {
...n,
parentId: tempData.originalParentId,
position: absolutePosition,
extent: tempData.originalParentId ? ('parent' as const) : undefined,
}
}
return n
})
)
groupDragTempParentsRef.current.clear()
}
// Get all selected nodes to update their positions too // Get all selected nodes to update their positions too
const allNodes = getNodes() const allNodes = getNodes()
const selectedNodes = allNodes.filter((n) => n.selected) let selectedNodes = allNodes.filter((n) => n.selected)
// If multiple nodes are selected, update all their positions // If the dragged node is in a group, include all group members
if (draggedBlockGroupId) {
const groups = getGroups()
const group = groups[draggedBlockGroupId]
if (group && group.blockIds.length > 1) {
const groupBlockIds = new Set(group.blockIds)
// Include the dragged node and all group members that aren't already selected
const groupNodes = allNodes.filter(
(n) => groupBlockIds.has(n.id) && !selectedNodes.some((sn) => sn.id === n.id)
)
selectedNodes = [...selectedNodes, ...groupNodes]
// Also ensure the dragged node is included
if (!selectedNodes.some((n) => n.id === node.id)) {
const draggedNode = allNodes.find((n) => n.id === node.id)
if (draggedNode) {
selectedNodes = [...selectedNodes, draggedNode]
}
}
}
}
// If multiple nodes are selected (or in a group), update all their positions
if (selectedNodes.length > 1) { if (selectedNodes.length > 1) {
const positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes) // Use pre-computed positions for group members, otherwise use computeClampedPositionUpdates
let positionUpdates: Array<{ id: string; position: { x: number; y: number } }>
if (computedGroupPositions.size > 0) {
// For group drags, use the pre-computed absolute positions
positionUpdates = selectedNodes.map((n) => {
const precomputedPos = computedGroupPositions.get(n.id)
if (precomputedPos) {
return { id: n.id, position: precomputedPos }
}
// For non-group members, use current position
return { id: n.id, position: n.position }
})
} else {
positionUpdates = computeClampedPositionUpdates(selectedNodes, blocks, allNodes)
}
collaborativeBatchUpdatePositions(positionUpdates, { collaborativeBatchUpdatePositions(positionUpdates, {
previousPositions: multiNodeDragStartRef.current, previousPositions: multiNodeDragStartRef.current,
}) })
@@ -2843,6 +3106,7 @@ const WorkflowContent = React.memo(() => {
}, },
[ [
getNodes, getNodes,
setNodes,
dragStartParentId, dragStartParentId,
potentialParentId, potentialParentId,
updateNodeParent, updateNodeParent,
@@ -2861,6 +3125,7 @@ const WorkflowContent = React.memo(() => {
activeWorkflowId, activeWorkflowId,
collaborativeBatchUpdatePositions, collaborativeBatchUpdatePositions,
collaborativeBatchUpdateParent, collaborativeBatchUpdateParent,
getGroups,
] ]
) )
@@ -3168,19 +3433,81 @@ const WorkflowContent = React.memo(() => {
/** /**
* Handles node click to select the node in ReactFlow. * Handles node click to select the node in ReactFlow.
* When clicking on a grouped block, also selects all other blocks in the group.
* Grouped blocks are marked with isGroupedSelection for different visual styling.
* Parent-child conflict resolution happens automatically in onNodesChange. * Parent-child conflict resolution happens automatically in onNodesChange.
*/ */
const handleNodeClick = useCallback( const handleNodeClick = useCallback(
(event: React.MouseEvent, node: Node) => { (event: React.MouseEvent, node: Node) => {
const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey const isMultiSelect = event.shiftKey || event.metaKey || event.ctrlKey
setNodes((nodes) => const groups = getGroups()
nodes.map((n) => ({
...n, // Track which nodes are directly clicked vs. group-expanded
selected: isMultiSelect ? (n.id === node.id ? true : n.selected) : n.id === node.id, const directlySelectedIds = new Set<string>()
}))
) setNodes((nodes) => {
// First, calculate the base selection
let updatedNodes = nodes.map((n) => {
const isDirectlySelected = isMultiSelect
? n.id === node.id
? true
: n.selected
: n.id === node.id
if (isDirectlySelected) {
directlySelectedIds.add(n.id)
}
return {
...n,
selected: isDirectlySelected,
data: {
...n.data,
isGroupedSelection: false, // Reset grouped selection flag
},
}
})
// Expand selection to include all group members
const selectedNodeIds = new Set(updatedNodes.filter((n) => n.selected).map((n) => n.id))
const groupsToInclude = new Set<string>()
// Find all groups that have at least one selected member
selectedNodeIds.forEach((nodeId) => {
const groupId = blocks[nodeId]?.data?.groupId
if (groupId && groups[groupId]) {
groupsToInclude.add(groupId)
}
})
// Add all blocks from those groups to the selection
if (groupsToInclude.size > 0) {
const expandedNodeIds = new Set(selectedNodeIds)
groupsToInclude.forEach((groupId) => {
const group = groups[groupId]
if (group) {
group.blockIds.forEach((blockId) => expandedNodeIds.add(blockId))
}
})
// Update nodes with expanded selection, marking group-expanded nodes
if (expandedNodeIds.size > selectedNodeIds.size) {
updatedNodes = updatedNodes.map((n) => {
const isGroupExpanded = expandedNodeIds.has(n.id) && !directlySelectedIds.has(n.id)
return {
...n,
selected: expandedNodeIds.has(n.id) ? true : n.selected,
data: {
...n.data,
isGroupedSelection: isGroupExpanded,
},
}
})
}
}
return updatedNodes
})
}, },
[setNodes] [setNodes, blocks, getGroups]
) )
/** Handles edge selection with container context tracking and Shift-click multi-selection. */ /** Handles edge selection with container context tracking and Shift-click multi-selection. */
@@ -3415,6 +3742,8 @@ const WorkflowContent = React.memo(() => {
onRemoveFromSubflow={handleContextRemoveFromSubflow} onRemoveFromSubflow={handleContextRemoveFromSubflow}
onOpenEditor={handleContextOpenEditor} onOpenEditor={handleContextOpenEditor}
onRename={handleContextRename} onRename={handleContextRename}
onGroupBlocks={handleContextGroupBlocks}
onUngroupBlocks={handleContextUngroupBlocks}
hasClipboard={hasClipboard()} hasClipboard={hasClipboard()}
showRemoveFromSubflow={contextMenuBlocks.some( showRemoveFromSubflow={contextMenuBlocks.some(
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel') (b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')

View File

@@ -424,6 +424,35 @@ export function useCollaborativeWorkflow() {
logger.info('Successfully applied batch-update-parent from remote user') logger.info('Successfully applied batch-update-parent from remote user')
break break
} }
case BLOCKS_OPERATIONS.GROUP_BLOCKS: {
const { blockIds, groupId } = payload
logger.info('Received group-blocks from remote user', {
userId,
groupId,
blockCount: (blockIds || []).length,
})
if (blockIds && blockIds.length > 0 && groupId) {
workflowStore.groupBlocks(blockIds, groupId)
}
logger.info('Successfully applied group-blocks from remote user')
break
}
case BLOCKS_OPERATIONS.UNGROUP_BLOCKS: {
const { groupId } = payload
logger.info('Received ungroup-blocks from remote user', {
userId,
groupId,
})
if (groupId) {
workflowStore.ungroupBlocks(groupId)
}
logger.info('Successfully applied ungroup-blocks from remote user')
break
}
} }
} }
} catch (error) { } catch (error) {
@@ -1584,6 +1613,83 @@ export function useCollaborativeWorkflow() {
] ]
) )
const collaborativeGroupBlocks = useCallback(
(blockIds: string[]) => {
if (!isInActiveRoom()) {
logger.debug('Skipping group blocks - not in active workflow')
return null
}
if (blockIds.length < 2) {
logger.debug('Cannot group fewer than 2 blocks')
return null
}
const groupId = crypto.randomUUID()
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: BLOCKS_OPERATIONS.GROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { blockIds, groupId },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
workflowStore.groupBlocks(blockIds, groupId)
undoRedo.recordGroupBlocks(blockIds, groupId)
logger.info('Grouped blocks collaboratively', { groupId, blockCount: blockIds.length })
return groupId
},
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
)
const collaborativeUngroupBlocks = useCallback(
(groupId: string) => {
if (!isInActiveRoom()) {
logger.debug('Skipping ungroup blocks - not in active workflow')
return []
}
const groups = workflowStore.getGroups()
const group = groups[groupId]
if (!group) {
logger.warn('Cannot ungroup - group not found', { groupId })
return []
}
const blockIds = [...group.blockIds]
const operationId = crypto.randomUUID()
addToQueue({
id: operationId,
operation: {
operation: BLOCKS_OPERATIONS.UNGROUP_BLOCKS,
target: OPERATION_TARGETS.BLOCKS,
payload: { groupId, blockIds },
},
workflowId: activeWorkflowId || '',
userId: session?.user?.id || 'unknown',
})
workflowStore.ungroupBlocks(groupId)
undoRedo.recordUngroupBlocks(groupId, blockIds)
logger.info('Ungrouped blocks collaboratively', { groupId, blockCount: blockIds.length })
return blockIds
},
[addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo]
)
return { return {
// Connection status // Connection status
isConnected, isConnected,
@@ -1622,6 +1728,10 @@ export function useCollaborativeWorkflow() {
collaborativeUpdateIterationCount, collaborativeUpdateIterationCount,
collaborativeUpdateIterationCollection, collaborativeUpdateIterationCollection,
// Collaborative block group operations
collaborativeGroupBlocks,
collaborativeUngroupBlocks,
// Direct access to stores for non-collaborative operations // Direct access to stores for non-collaborative operations
workflowStore, workflowStore,
subBlockStore, subBlockStore,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -95,6 +95,7 @@ const initialState = {
edges: [], edges: [],
loops: {}, loops: {},
parallels: {}, parallels: {},
groups: {},
lastSaved: undefined, lastSaved: undefined,
deploymentStatuses: {}, deploymentStatuses: {},
needsRedeployment: false, needsRedeployment: false,
@@ -577,6 +578,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
edges: state.edges, edges: state.edges,
loops: state.loops, loops: state.loops,
parallels: state.parallels, parallels: state.parallels,
groups: state.groups,
lastSaved: state.lastSaved, lastSaved: state.lastSaved,
deploymentStatuses: state.deploymentStatuses, deploymentStatuses: state.deploymentStatuses,
needsRedeployment: state.needsRedeployment, needsRedeployment: state.needsRedeployment,
@@ -597,6 +599,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
Object.keys(workflowState.parallels || {}).length > 0 Object.keys(workflowState.parallels || {}).length > 0
? workflowState.parallels ? workflowState.parallels
: generateParallelBlocks(nextBlocks) : generateParallelBlocks(nextBlocks)
const nextGroups = workflowState.groups || state.groups
return { return {
...state, ...state,
@@ -604,6 +607,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
edges: nextEdges, edges: nextEdges,
loops: nextLoops, loops: nextLoops,
parallels: nextParallels, parallels: nextParallels,
groups: nextGroups,
deploymentStatuses: workflowState.deploymentStatuses || state.deploymentStatuses, deploymentStatuses: workflowState.deploymentStatuses || state.deploymentStatuses,
needsRedeployment: needsRedeployment:
workflowState.needsRedeployment !== undefined workflowState.needsRedeployment !== undefined
@@ -1333,6 +1337,126 @@ export const useWorkflowStore = create<WorkflowStore>()(
getDragStartPosition: () => { getDragStartPosition: () => {
return get().dragStartPosition || null return get().dragStartPosition || null
}, },
groupBlocks: (blockIds: string[], groupId?: string) => {
if (blockIds.length === 0) return ''
const newGroupId = groupId || crypto.randomUUID()
const currentGroups = get().groups || {}
const currentBlocks = get().blocks
// Create the new group with all selected block IDs
const updatedGroups = { ...currentGroups }
updatedGroups[newGroupId] = {
id: newGroupId,
blockIds: [...blockIds],
}
// Update blocks: set groupId and push to groupStack
const newBlocks = { ...currentBlocks }
for (const blockId of blockIds) {
if (newBlocks[blockId]) {
const currentData = newBlocks[blockId].data || {}
const currentStack = Array.isArray(currentData.groupStack) ? currentData.groupStack : []
newBlocks[blockId] = {
...newBlocks[blockId],
data: {
...currentData,
groupId: newGroupId,
groupStack: [...currentStack, newGroupId],
},
}
}
}
set({
blocks: newBlocks,
groups: updatedGroups,
})
get().updateLastSaved()
logger.info('Created block group', {
groupId: newGroupId,
blockCount: blockIds.length,
})
return newGroupId
},
ungroupBlocks: (groupId: string) => {
const currentGroups = get().groups || {}
const currentBlocks = get().blocks
const group = currentGroups[groupId]
if (!group) {
logger.warn('Attempted to ungroup non-existent group', { groupId })
return []
}
const blockIds = [...group.blockIds]
// Remove the group from the groups record
const updatedGroups = { ...currentGroups }
delete updatedGroups[groupId]
// Update blocks: pop from groupStack and set groupId to the previous level
const newBlocks = { ...currentBlocks }
for (const blockId of blockIds) {
if (newBlocks[blockId]) {
const currentData = { ...newBlocks[blockId].data }
const currentStack = Array.isArray(currentData.groupStack)
? [...currentData.groupStack]
: []
// Pop the current groupId from the stack
if (currentStack.length > 0 && currentStack[currentStack.length - 1] === groupId) {
currentStack.pop()
}
// The new groupId is the top of the remaining stack, or undefined if empty
const newGroupId =
currentStack.length > 0 ? currentStack[currentStack.length - 1] : undefined
if (newGroupId) {
currentData.groupId = newGroupId
currentData.groupStack = currentStack
} else {
// Remove groupId and groupStack if stack is empty
delete currentData.groupId
delete currentData.groupStack
}
newBlocks[blockId] = {
...newBlocks[blockId],
data: currentData,
}
}
}
set({
blocks: newBlocks,
groups: updatedGroups,
})
get().updateLastSaved()
logger.info('Ungrouped blocks', {
groupId,
blockCount: blockIds.length,
})
return blockIds
},
getGroupBlockIds: (groupId: string) => {
const groups = get().groups || {}
const group = groups[groupId]
if (!group) return []
return [...group.blockIds]
},
getGroups: () => {
return get().groups || {}
},
}), }),
{ name: 'workflow-store' } { name: 'workflow-store' }
) )

View File

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