Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d533ea2
feat(canvas): added the ability to lock blocks
waleedlatif1 Jan 31, 2026
6907c88
unlock duplicates of locked blocks
waleedlatif1 Feb 1, 2026
4f342d3
Merge origin/staging into feat/lock
waleedlatif1 Feb 1, 2026
63eba0f
fix(duplicate): place duplicate outside locked container
waleedlatif1 Feb 1, 2026
c19263e
fix(duplicate): unlock all blocks when duplicating workflow
waleedlatif1 Feb 1, 2026
3f908d6
fix code block disabled state, allow unlock from editor
waleedlatif1 Feb 1, 2026
73856af
fix(lock): address code review feedback
waleedlatif1 Feb 1, 2026
bd36283
fix(lock): prevent unlocking blocks inside locked containers
waleedlatif1 Feb 1, 2026
ecb13a5
fix(lock): ensure consistent behavior across all UIs
waleedlatif1 Feb 1, 2026
da5e0aa
fix(enable): consistent behavior - can't enable if parent disabled
waleedlatif1 Feb 1, 2026
ee9f2e3
docs(quick-reference): add lock block action
waleedlatif1 Feb 1, 2026
ab4b09c
remove prefix square brackets in error notif
waleedlatif1 Feb 1, 2026
7714dad
add lock block image
waleedlatif1 Feb 1, 2026
901bffe
fix(block-menu): paste should not be disabled for locked selection
waleedlatif1 Feb 1, 2026
c987b6f
refactor(workflow): extract block deletion protection into shared uti…
waleedlatif1 Feb 1, 2026
8dad4d4
refactor(workflow): extend block protection utilities for edge protec…
waleedlatif1 Feb 1, 2026
4c05ae1
fix(lock): address review comments for lock feature
waleedlatif1 Feb 1, 2026
802884f
fix(copilot): add lock checks for insert and extract operations
waleedlatif1 Feb 1, 2026
0eea69b
fix(lock): prevent duplicates inside locked containers via regenerate…
waleedlatif1 Feb 1, 2026
395e6ed
fix(lock): fix toggle locked target state and draggable check
waleedlatif1 Feb 1, 2026
ef4acfd
fix(copilot): check parent lock in edit and delete operations
waleedlatif1 Feb 1, 2026
3664a56
fix(socket): add server-side lock validation and admin-only permissions
waleedlatif1 Feb 1, 2026
3fbcfc6
test(socket): update permission test for admin-only lock toggle
waleedlatif1 Feb 1, 2026
52d9f31
fix(undo-redo): use consistent target state for toggle redo
waleedlatif1 Feb 1, 2026
813ec9b
fix(socket): add comprehensive lock validation across operations
waleedlatif1 Feb 1, 2026
a826b97
refactor(workflow): use pre-computed lock state from contextMenuBlocks
waleedlatif1 Feb 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/docs/content/docs/en/quick-reference/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
<td>Right-click → **Enable/Disable**</td>
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
</tr>
<tr>
<td>Lock/Unlock a block</td>
<td>Hover block → Click lock icon (Admin only)</td>
<td><ActionImage src="/static/quick-reference/lock-block.png" alt="Lock block" /></td>
</tr>
<tr>
<td>Toggle handle orientation</td>
<td>Right-click → **Toggle Handles**</td>
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/en/tools/pulse.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
/>

{/* MANUAL-CONTENT-START:intro */}
The [Pulse](https://www.pulseapi.com/) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.
The [Pulse](https://www.runpulse.com) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.

With Pulse, you can:

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { memo, useCallback } from 'react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react'
import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
Expand Down Expand Up @@ -49,6 +49,7 @@ export const ActionBar = memo(
collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled,
collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow()
const { setPendingSelection } = useWorkflowRegistry()
const { handleRunFromBlock } = useWorkflowExecution()
Expand Down Expand Up @@ -84,16 +85,28 @@ export const ActionBar = memo(
)
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])

const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
const {
isEnabled,
horizontalHandles,
parentId,
parentType,
isLocked,
isParentLocked,
isParentDisabled,
} = useWorkflowStore(
useCallback(
(state) => {
const block = state.blocks[blockId]
const parentId = block?.data?.parentId
const parentBlock = parentId ? state.blocks[parentId] : undefined
return {
isEnabled: block?.enabled ?? true,
horizontalHandles: block?.horizontalHandles ?? false,
parentId,
parentType: parentId ? state.blocks[parentId]?.type : undefined,
parentType: parentBlock?.type,
isLocked: block?.locked ?? false,
isParentLocked: parentBlock?.locked ?? false,
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
}
},
[blockId]
Expand Down Expand Up @@ -159,52 +172,90 @@ export const ActionBar = memo(
)}
>
{!isNoteBlock && !isInsideSubflow && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='inline-flex'>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (canRunFromBlock && !disabled) {
handleRunFromBlockClick()
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || !canRunFromBlock}
>
<PlayOutline className={ICON_SIZE} />
</Button>
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{(() => {
if (disabled) return getTooltipMessage('Run from block')
if (isExecuting) return 'Execution in progress'
if (!dependenciesSatisfied) return 'Run previous blocks first'
return 'Run from block'
})()}
</Tooltip.Content>
</Tooltip.Root>
)}

{!isNoteBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (canRunFromBlock && !disabled) {
handleRunFromBlockClick()
// Can't enable if parent is disabled (must enable parent first)
const cantEnable = !isEnabled && isParentDisabled
if (!disabled && !isLocked && !isParentLocked && !cantEnable) {
collaborativeBatchToggleBlockEnabled([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || !canRunFromBlock}
disabled={
disabled || isLocked || isParentLocked || (!isEnabled && isParentDisabled)
}
>
<PlayOutline className={ICON_SIZE} />
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{(() => {
if (disabled) return getTooltipMessage('Run from block')
if (isExecuting) return 'Execution in progress'
if (!dependenciesSatisfied) return 'Run upstream blocks first'
return 'Run from block'
})()}
{isLocked || isParentLocked
? 'Block is locked'
: !isEnabled && isParentDisabled
? 'Parent container is disabled'
: getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
</Tooltip.Content>
</Tooltip.Root>
)}

{!isNoteBlock && (
{userPermissions.canAdmin && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
collaborativeBatchToggleBlockEnabled([blockId])
// Can't unlock a block if its parent container is locked
if (!disabled && !(isLocked && isParentLocked)) {
collaborativeBatchToggleLocked([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled}
disabled={disabled || (isLocked && isParentLocked)}
>
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
{isLocked ? <Unlock className={ICON_SIZE} /> : <Lock className={ICON_SIZE} />}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
{isLocked && isParentLocked
? 'Parent container is locked'
: isLocked
? 'Unlock Block'
: 'Lock Block'}
</Tooltip.Content>
</Tooltip.Root>
)}
Expand Down Expand Up @@ -237,12 +288,12 @@ export const ActionBar = memo(
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
if (!disabled && !isLocked && !isParentLocked) {
collaborativeBatchToggleBlockHandles([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled}
disabled={disabled || isLocked || isParentLocked}
>
{horizontalHandles ? (
<ArrowLeftRight className={ICON_SIZE} />
Expand All @@ -252,7 +303,9 @@ export const ActionBar = memo(
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</Tooltip.Content>
</Tooltip.Root>
)}
Expand All @@ -264,19 +317,23 @@ export const ActionBar = memo(
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled && userPermissions.canEdit) {
if (!disabled && userPermissions.canEdit && !isLocked && !isParentLocked) {
window.dispatchEvent(
new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } })
)
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled || !userPermissions.canEdit}
disabled={disabled || !userPermissions.canEdit || isLocked || isParentLocked}
>
<LogOut className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
<Tooltip.Content side='top'>
{isLocked || isParentLocked
? 'Block is locked'
: getTooltipMessage('Remove from Subflow')}
</Tooltip.Content>
</Tooltip.Root>
)}

Expand All @@ -286,17 +343,19 @@ export const ActionBar = memo(
variant='ghost'
onClick={(e) => {
e.stopPropagation()
if (!disabled) {
if (!disabled && !isLocked && !isParentLocked) {
collaborativeBatchRemoveBlocks([blockId])
}
}}
className={ACTION_BUTTON_STYLES}
disabled={disabled}
disabled={disabled || isLocked || isParentLocked}
>
<Trash2 className={ICON_SIZE} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content>
<Tooltip.Content side='top'>
{isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')}
</Tooltip.Content>
</Tooltip.Root>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export interface BlockInfo {
horizontalHandles: boolean
parentId?: string
parentType?: string
locked?: boolean
isParentLocked?: boolean
isParentDisabled?: boolean
}

/**
Expand All @@ -46,10 +49,17 @@ export interface BlockMenuProps {
showRemoveFromSubflow?: boolean
/** Whether run from block is available (has snapshot, was executed, not inside subflow) */
canRunFromBlock?: boolean
/** Whether to disable edit actions (user can't edit OR blocks are locked) */
disableEdit?: boolean
/** Whether the user has edit permission (ignoring locked state) */
userCanEdit?: boolean
isExecuting?: boolean
/** Whether the selected block is a trigger (has no incoming edges) */
isPositionalTrigger?: boolean
/** Callback to toggle locked state of selected blocks */
onToggleLocked?: () => void
/** Whether the user has admin permissions */
canAdmin?: boolean
}

/**
Expand Down Expand Up @@ -78,13 +88,22 @@ export function BlockMenu({
showRemoveFromSubflow = false,
canRunFromBlock = false,
disableEdit = false,
userCanEdit = true,
isExecuting = false,
isPositionalTrigger = false,
onToggleLocked,
canAdmin = false,
}: BlockMenuProps) {
const isSingleBlock = selectedBlocks.length === 1

const allEnabled = selectedBlocks.every((b) => b.enabled)
const allDisabled = selectedBlocks.every((b) => !b.enabled)
const allLocked = selectedBlocks.every((b) => b.locked)
const allUnlocked = selectedBlocks.every((b) => !b.locked)
// Can't unlock blocks that have locked parents
const hasBlockWithLockedParent = selectedBlocks.some((b) => b.locked && b.isParentLocked)
// Can't enable blocks that have disabled parents
const hasBlockWithDisabledParent = selectedBlocks.some((b) => !b.enabled && b.isParentDisabled)

const hasSingletonBlock = selectedBlocks.some(
(b) =>
Expand All @@ -108,6 +127,12 @@ export function BlockMenu({
return 'Toggle Enabled'
}

const getToggleLockedLabel = () => {
if (allLocked) return 'Unlock'
if (allUnlocked) return 'Lock'
return 'Toggle Lock'
}

return (
<Popover
open={isOpen}
Expand Down Expand Up @@ -139,7 +164,7 @@ export function BlockMenu({
</PopoverItem>
<PopoverItem
className='group'
disabled={disableEdit || !hasClipboard}
disabled={!userCanEdit || !hasClipboard}
onClick={() => {
onPaste()
onClose()
Expand All @@ -150,7 +175,7 @@ export function BlockMenu({
</PopoverItem>
{!hasSingletonBlock && (
<PopoverItem
disabled={disableEdit}
disabled={!userCanEdit}
onClick={() => {
onDuplicate()
onClose()
Expand All @@ -164,13 +189,15 @@ export function BlockMenu({
{!allNoteBlocks && <PopoverDivider />}
{!allNoteBlocks && (
<PopoverItem
disabled={disableEdit}
disabled={disableEdit || hasBlockWithDisabledParent}
onClick={() => {
onToggleEnabled()
onClose()
if (!disableEdit && !hasBlockWithDisabledParent) {
onToggleEnabled()
onClose()
}
}}
>
{getToggleEnabledLabel()}
{hasBlockWithDisabledParent ? 'Parent is disabled' : getToggleEnabledLabel()}
</PopoverItem>
)}
{!allNoteBlocks && !isSubflow && (
Expand All @@ -195,6 +222,19 @@ export function BlockMenu({
Remove from Subflow
</PopoverItem>
)}
{canAdmin && onToggleLocked && (
<PopoverItem
disabled={hasBlockWithLockedParent}
onClick={() => {
if (!hasBlockWithLockedParent) {
onToggleLocked()
onClose()
}
}}
>
{hasBlockWithLockedParent ? 'Parent is locked' : getToggleLockedLabel()}
</PopoverItem>
)}

{/* Single block actions */}
{isSingleBlock && <PopoverDivider />}
Expand Down
Loading