diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts
index 697ba4099c0..0086975edad 100644
--- a/apps/realtime/src/database/operations.ts
+++ b/apps/realtime/src/database/operations.ts
@@ -734,6 +734,33 @@ async function handleBlockOperationTx(
break
}
+ case BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED: {
+ if (!payload.id || payload.errorEnabled === undefined) {
+ throw new Error('Missing required fields for update error enabled operation')
+ }
+
+ const updateResult = await tx
+ .update(workflowBlocks)
+ .set({
+ data: sql`jsonb_set(
+ coalesce(${workflowBlocks.data}, '{}'::jsonb),
+ '{errorEnabled}',
+ ${JSON.stringify(payload.errorEnabled)}::jsonb,
+ true
+ )`,
+ updatedAt: new Date(),
+ })
+ .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
+ .returning({ id: workflowBlocks.id })
+
+ if (updateResult.length === 0) {
+ throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`)
+ }
+
+ logger.debug(`Updated block error output: ${payload.id} -> ${payload.errorEnabled}`)
+ break
+ }
+
case BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE: {
if (!payload.id || !payload.canonicalId || !payload.canonicalMode) {
throw new Error('Missing required fields for update canonical mode operation')
diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts
index 4f4a4296c16..88679cb5622 100644
--- a/apps/realtime/src/middleware/permissions.ts
+++ b/apps/realtime/src/middleware/permissions.ts
@@ -27,6 +27,7 @@ const WRITE_OPERATIONS: string[] = [
BLOCK_OPERATIONS.TOGGLE_ENABLED,
BLOCK_OPERATIONS.UPDATE_PARENT,
BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE,
+ BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED,
BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE,
BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES,
BLOCK_OPERATIONS.TOGGLE_HANDLES,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx
index 08bde247dbc..97307ce5c4b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx
@@ -1,6 +1,6 @@
import { memo, useCallback } from 'react'
import { Button, cn, Duplicate, PlayOutline, Tooltip, Trash2, toast } from '@sim/emcn'
-import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react'
+import { Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -14,14 +14,16 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 }
const ACTION_BUTTON_STYLES = [
- 'size-[23px] rounded-lg p-0',
- 'border border-[var(--border)] bg-[var(--surface-5)]',
- 'text-[var(--text-secondary)]',
- 'hover-hover:border-transparent hover-hover:bg-[var(--brand-secondary)] hover-hover:!text-[var(--text-inverse)]',
- 'dark:border-transparent dark:bg-[var(--surface-7)] dark:hover-hover:bg-[var(--brand-secondary)]',
+ 'size-[24px] rounded-md p-0',
+ 'border-none bg-transparent text-[var(--text-icon)]',
+ 'hover-hover:bg-[var(--surface-5)] hover-hover:!text-[var(--text-primary)]',
+ 'dark:hover-hover:bg-[var(--surface-4)]',
+ 'transition-[background-color,color,opacity,transform] duration-150 active:scale-[0.96]',
].join(' ')
-const ICON_SIZE = 'size-[11px]'
+const ICON_SIZE = 'size-[14px]'
+
+type ActionId = 'run' | 'enabled' | 'lock' | 'duplicate' | 'remove' | 'delete'
/**
* Props for the ActionBar component
@@ -33,6 +35,8 @@ interface ActionBarProps {
blockType: string
/** Whether the action bar is disabled */
disabled?: boolean
+ /** Places the actions inside the workflow card's border swell. */
+ variant?: 'floating' | 'swell'
}
/**
@@ -42,12 +46,16 @@ interface ActionBarProps {
* @component
*/
export const ActionBar = memo(
- function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) {
+ function ActionBar({
+ blockId,
+ blockType,
+ disabled = false,
+ variant = 'floating',
+ }: ActionBarProps) {
const {
collaborativeBatchAddBlocks,
collaborativeBatchRemoveBlocks,
collaborativeBatchToggleBlockEnabled,
- collaborativeBatchToggleBlockHandles,
collaborativeBatchToggleLocked,
} = useCollaborativeWorkflow()
const { setPendingSelection } = useWorkflowRegistry()
@@ -78,30 +86,22 @@ export const ActionBar = memo(
)
}, [blockId, collaborativeBatchAddBlocks, setPendingSelection])
- const {
- isEnabled,
- horizontalHandles,
- parentId,
- parentType,
- isLocked,
- isParentLocked,
- isParentDisabled,
- } = useWorkflowStore(
- useShallow((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: parentBlock?.type,
- isLocked: block?.locked ?? false,
- isParentLocked: parentBlock?.locked ?? false,
- isParentDisabled: parentBlock ? !parentBlock.enabled : false,
- }
- })
- )
+ const { isEnabled, parentId, parentType, isLocked, isParentLocked, isParentDisabled } =
+ useWorkflowStore(
+ useShallow((state) => {
+ const block = state.blocks[blockId]
+ const parentId = block?.data?.parentId
+ const parentBlock = parentId ? state.blocks[parentId] : undefined
+ return {
+ isEnabled: block?.enabled ?? true,
+ parentId,
+ parentType: parentBlock?.type,
+ isLocked: block?.locked ?? false,
+ isParentLocked: parentBlock?.locked ?? false,
+ isParentDisabled: parentBlock ? !parentBlock.enabled : false,
+ }
+ })
+ )
const { activeWorkflowId } = useWorkflowRegistry()
const isExecuting = useIsCurrentWorkflowExecuting()
@@ -112,7 +112,6 @@ export const ActionBar = memo(
const isStartBlock = isInputDefinitionTrigger(blockType)
const isResponseBlock = blockType === 'response'
const isNoteBlock = blockType === 'note'
- const isSubflowBlock = blockType === 'loop' || blockType === 'parallel'
const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel')
const snapshot = activeWorkflowId ? getLastExecutionSnapshot(activeWorkflowId) : null
@@ -132,6 +131,61 @@ export const ActionBar = memo(
isTriggerBlock || (snapshot && incomingEdges.every((edge) => isSourceSatisfied(edge.source)))
const canRunFromBlock =
dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting
+ const isSwell = variant === 'swell'
+ const firstActionId: ActionId =
+ !isNoteBlock && !isInsideSubflow
+ ? 'run'
+ : !isNoteBlock
+ ? 'enabled'
+ : userPermissions.canAdmin
+ ? 'lock'
+ : !isStartBlock && !isResponseBlock
+ ? 'duplicate'
+ : 'delete'
+ /*
+ * Icon treatment follows the swell's own fill, published by the card view
+ * as `data-node-selected`. Keying off React Flow's raw `selected` would
+ * diverge: an executing block keeps the success ring, so the swell stays
+ * gray (or retracts) while `selected` is still true.
+ */
+ const actionButtonStyles = cn(
+ ACTION_BUTTON_STYLES,
+ isSwell && [
+ 'group-data-[node-selected]:text-[var(--surface-2)]',
+ 'hover-hover:group-data-[node-selected]:bg-[var(--surface-2)]',
+ 'hover-hover:group-data-[node-selected]:!text-[var(--text-primary)]',
+ ]
+ )
+ /*
+ * End-button silhouettes: a straight diagonal edge running parallel to
+ * the gray swell's taper above it (slope 20/24 ≈ 40° from vertical — the
+ * falloff curve's central gradient), blended into the top edge with a
+ * generous r8 arc and meeting the bottom edge with a pointier r2.5 arc;
+ * the outer corners keep a modest r4. The right (delete) shape is the
+ * exact mirror of the left (first action) shape. Width is 40px — outer
+ * diagonal pushed out so the glyph has room before the cut; inner side
+ * stays tight against neighboring actions. The row is right-[24px] to
+ * match the swell anchor inset (right-aligned on the card). Glyphs shift
+ * away from the outer cut (+6 / -6). Play gets an extra +2px because the
+ * triangle’s optical center sits left of its viewBox center.
+ */
+ const getActionButtonStyles = (actionId: ActionId) =>
+ cn(
+ actionButtonStyles,
+ isSwell &&
+ actionId === firstActionId &&
+ "!w-[40px] [clip-path:path('M23.75_0A8_8_0_0_0_17.6_2.88L3.41_19.9A2.5_2.5_0_0_0_5.34_24L36_24A4_4_0_0_0_40_20L40_4A4_4_0_0_0_36_0Z')] [&_svg]:translate-y-px",
+ isSwell &&
+ actionId === firstActionId &&
+ (actionId === 'run' ? '[&_svg]:translate-x-[8px]' : '[&_svg]:translate-x-[6px]'),
+ isSwell &&
+ actionId === 'delete' &&
+ "!w-[40px] [clip-path:path('M16.25_0A8_8_0_0_1_22.4_2.88L36.59_19.9A2.5_2.5_0_0_1_34.66_24L4_24A4_4_0_0_1_0_20L0_4A4_4_0_0_1_4_0Z')] [&_svg]:-translate-x-[6px] [&_svg]:translate-y-px",
+ /* `!` is required: these buttons are also `disabled` when locked, and
+ the emcn Button base carries `disabled:opacity-70`, which outranks a
+ plain `opacity-35` on specificity. */
+ actionId !== 'lock' && isLocked && '!opacity-35'
+ )
const handleRunFromBlockClick = useCallback(() => {
if (!activeWorkflowId || !canRunFromBlock) return
@@ -153,205 +207,210 @@ export const ActionBar = memo(
return (
- {!isNoteBlock && !isInsideSubflow && (
-
-
-
-
-
-
-
- {(() => {
- if (disabled) return getTooltipMessage('Run from block')
- if (isExecuting) return 'Running...'
- if (!dependenciesSatisfied) return 'Run previous blocks first'
- return 'Run from block'
- })()}
-
-
- )}
+
+
+ {!isNoteBlock && !isInsideSubflow && (
+
+
+
+
+
+
+
+ {(() => {
+ if (isLocked || isParentLocked) return 'Block is locked'
+ if (disabled) return getTooltipMessage('Run from block')
+ if (isExecuting) return 'Running...'
+ if (!dependenciesSatisfied) return 'Run previous blocks first'
+ return 'Run from block'
+ })()}
+
+
+ )}
- {!isNoteBlock && (
-
-
-
-
-
- {isLocked || isParentLocked
- ? 'Block is locked'
- : !isEnabled && isParentDisabled
- ? 'Parent container is disabled'
- : getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
-
-
- )}
+ {!isNoteBlock && (
+
+
+
+
+
+
+
+ {isLocked || isParentLocked
+ ? 'Block is locked'
+ : !isEnabled && isParentDisabled
+ ? 'Parent container is disabled'
+ : getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
+
+
+ )}
- {userPermissions.canAdmin && (
-
-
-
-
-
- {isLocked && isParentLocked
- ? 'Parent container is locked'
- : isLocked
- ? 'Unlock Block'
- : 'Lock Block'}
-
-
- )}
+ {userPermissions.canAdmin && (
+
+
+
+
+
+
+
+ {isLocked && isParentLocked
+ ? 'Parent container is locked'
+ : isLocked
+ ? 'Unlock Block'
+ : 'Lock Block'}
+
+
+ )}
- {!isStartBlock && !isResponseBlock && (
-
-
-
-
-
- {isLocked || isParentLocked
- ? 'Block is locked'
- : getTooltipMessage('Duplicate Block')}
-
-
- )}
+ {!isStartBlock && !isResponseBlock && (
+
+
+
+
+
+
+
+ {isLocked || isParentLocked
+ ? 'Block is locked'
+ : getTooltipMessage('Duplicate Block')}
+
+
+ )}
- {!isNoteBlock && !isSubflowBlock && (
-
-
-
-
-
- {isLocked || isParentLocked
- ? 'Block is locked'
- : getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
-
-
- )}
-
- {!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
-
-
-
-
-
- {isLocked || isParentLocked
- ? 'Block is locked'
- : getTooltipMessage('Remove from Subflow')}
-
-
- )}
+ {!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && (
+
+
+
+
+
+
+
+ {isLocked || isParentLocked
+ ? 'Block is locked'
+ : getTooltipMessage('Remove from Subflow')}
+
+
+ )}
-
-
-
-
-
- {isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')}
-
-
+
+
+
+
+
+
+
+ {isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')}
+
+
+
+
)
},
@@ -367,7 +426,8 @@ export const ActionBar = memo(
return (
prevProps.blockId === nextProps.blockId &&
prevProps.blockType === nextProps.blockType &&
- prevProps.disabled === nextProps.disabled
+ prevProps.disabled === nextProps.disabled &&
+ prevProps.variant === nextProps.variant
)
}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx
index acc8e422da7..c92301ffe9b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx
@@ -11,7 +11,6 @@ export interface BlockInfo {
id: string
type: string
enabled: boolean
- horizontalHandles: boolean
parentId?: string
parentType?: string
locked?: boolean
@@ -34,7 +33,6 @@ export interface BlockMenuProps {
onDuplicate: () => void
onDelete: () => void
onToggleEnabled: () => void
- onToggleHandles: () => void
onRemoveFromSubflow: () => void
onOpenEditor: () => void
onRename: () => void
@@ -74,7 +72,6 @@ export function BlockMenu({
onDuplicate,
onDelete,
onToggleEnabled,
- onToggleHandles,
onRemoveFromSubflow,
onOpenEditor,
onRename,
@@ -207,17 +204,6 @@ export function BlockMenu({
{hasBlockWithDisabledParent ? 'Parent is disabled' : getToggleEnabledLabel()}
)}
- {!allNoteBlocks && !isSubflow && (
- {
- onToggleHandles()
- onClose()
- }}
- >
- Flip Handles
-
- )}
{canRemoveFromSubflow && (
{(blockConfig || isSubflow) && currentBlock?.type !== 'note' && (
-
-
+
)}
{isRenaming ? (