diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
index df248f3b392..0976cdde23d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx
@@ -1,10 +1,9 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
-import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
import { createLogger } from '@sim/logger'
-import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
+import { SubBlockRowView, WorkflowBlockView } from '@sim/workflow-renderer'
import { isEqual } from 'es-toolkit'
import { useParams } from 'next/navigation'
-import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
+import { type NodeProps, useUpdateNodeInternals } from 'reactflow'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createMcpToolId } from '@/lib/mcp/shared'
@@ -371,25 +370,7 @@ const SubBlockRow = memo(function SubBlockRow({
const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value)
return (
-
-
- {title}
-
- {displayValue !== undefined && (
-
- {displayValue}
-
- )}
-
+
)
}, areSubBlockRowPropsEqual)
@@ -630,32 +611,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
- /**
- * Reusable styles and positioning for Handle components.
- */
- const getHandleClasses = (position: 'left' | 'right' | 'top' | 'bottom', isError = false) => {
- const baseClasses = '!z-[0] !cursor-crosshair !border-none !transition-[colors] !duration-150'
- const colorClasses = isError ? '!bg-[var(--text-error)]' : '!bg-[var(--workflow-edge)]'
-
- const positionClasses = {
- left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover-hover:!left-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-l-full',
- right:
- '!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover-hover:!right-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-r-full',
- top: '!top-[-8px] !h-[7px] !w-5 !rounded-t-[2px] !rounded-b-none hover-hover:!top-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-t-full',
- bottom:
- '!bottom-[-8px] !h-[7px] !w-5 !rounded-b-[2px] !rounded-t-none hover-hover:!bottom-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-b-full',
- }
-
- return cn(baseClasses, colorClasses, positionClasses[position])
- }
-
- const getHandleStyle = (position: 'horizontal' | 'vertical') => {
- if (position === 'horizontal') {
- return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
- }
- return { left: '50%', transform: 'translateX(-50%)' }
- }
-
/**
* Compute per-condition rows (title/value/id) for condition blocks so we can render
* one row per condition statement with its own output handle.
@@ -739,399 +694,136 @@ export const WorkflowBlock = memo(function WorkflowBlock({
type === 'schedule' && !isLoadingScheduleInfo && scheduleInfo !== null
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
- return (
-
-
handleKeyboardActivation(event, handleClick)}
- className={cn(
- 'workflow-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
- )}
- >
- {isPending && (
-
- Next Step
-
- )}
-
- {!data.isPreview && !data.isEmbedded && (
-
- )}
-
- {shouldShowDefaultHandles && (
-
{
- if (connection.source === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
+ const wouldCreateConnectionCycle = (source: string, target: string) =>
+ wouldCreateCycle(useWorkflowStore.getState().edges, source, target)
+
+ const webhookProviderName = webhookProvider ? getProviderName(webhookProvider) : undefined
+
+ const rows = (
+ <>
+ {type === 'condition' ? (
+ conditionRows.map((cond) => (
+
+ ))
+ ) : type === 'router_v2' ? (
+ <>
+
- )}
-
-
-
-
- {isWorkflowSelector &&
- childWorkflowId &&
- typeof childIsDeployed === 'boolean' &&
- (!childIsDeployed || childNeedsRedeploy) && (
-
-
- {
- e.stopPropagation()
- if (childWorkflowId && !isDeploying && userPermissions.canAdmin) {
- deployChildWorkflow({ workflowId: childWorkflowId })
- }
- }}
- >
- {isDeploying ? 'Deploying...' : !childIsDeployed ? 'undeployed' : 'redeploy'}
-
-
-
-
- {!userPermissions.canAdmin
- ? 'Admin permission required to deploy'
- : !childIsDeployed
- ? 'Click to deploy'
- : 'Click to redeploy'}
-
-
-
- )}
- {!isEnabled && !isLocked &&
disabled}
- {isLocked &&
locked}
-
- {type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
-
-
- {
- e.stopPropagation()
- if (scheduleInfo?.id) {
- reactivateSchedule(scheduleInfo.id)
- }
- }}
- >
- disabled
-
-
-
- Click to reactivate
-
-
- )}
-
- {showWebhookIndicator && (
-
-
-
- Webhook
-
-
-
- {webhookProvider && webhookPath ? (
- <>
- {getProviderName(webhookProvider)} Webhook
- Path: {webhookPath}
- >
- ) : (
-
- This workflow is triggered by a webhook.
-
- )}
-
-
- )}
-
- {isWebhookConfigured && isWebhookDisabled && webhookId && (
-
-
- {
- e.stopPropagation()
- reactivateWebhook(webhookId)
- }}
- >
- disabled
-
-
-
- Click to reactivate
-
-
- )}
- {/* {isActive && (
-
- )} */}
-
-
-
- {hasContentBelowHeader && (
-
- {type === 'condition' ? (
- conditionRows.map((cond) => (
-
- ))
- ) : type === 'router_v2' ? (
- <>
-
- {routerRows.map((route, index) => (
+ {routerRows.map((route, index) => (
+
+ ))}
+ >
+ ) : (
+ subBlockRows.map((row, rowIndex) =>
+ row.flatMap((subBlock) => {
+ const rawValue = subBlockState[subBlock.id]?.value
+ if (subBlock.type === 'mcp-dynamic-args') {
+ const schema = subBlockState._toolSchema?.value as
+ | { properties?: Record }
+ | undefined
+ const properties = schema?.properties
+ if (properties && typeof properties === 'object') {
+ const args = (rawValue && typeof rawValue === 'object' ? rawValue : {}) as Record<
+ string,
+ unknown
+ >
+ return Object.keys(properties).map((paramName) => (
- ))}
- >
- ) : (
- subBlockRows.map((row, rowIndex) =>
- row.flatMap((subBlock) => {
- const rawValue = subBlockState[subBlock.id]?.value
- if (subBlock.type === 'mcp-dynamic-args') {
- const schema = subBlockState._toolSchema?.value as
- | { properties?: Record }
- | undefined
- const properties = schema?.properties
- if (properties && typeof properties === 'object') {
- const args = (
- rawValue && typeof rawValue === 'object' ? rawValue : {}
- ) as Record
- return Object.keys(properties).map((paramName) => (
-
- ))
- }
- return []
- }
- return [
- ,
- ]
- })
- )
- )}
- {shouldShowDefaultHandles && }
-
- )}
-
- {type === 'condition' && (
- <>
- {conditionRows.map((cond, condIndex) => {
- const topOffset =
- HANDLE_POSITIONS.CONDITION_START_Y +
- condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
- return (
- {
- if (connection.target === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
- )
- })}
- {
- if (connection.target === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
- >
- )}
-
- {type === 'router_v2' && (
- <>
- {routerRows.map((route, routeIndex) => {
- // +1 row offset for context row at the top
- const topOffset =
- HANDLE_POSITIONS.CONDITION_START_Y +
- (routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
- return (
- {
- if (connection.target === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
- )
- })}
- {
- if (connection.target === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
- >
- )}
-
- {type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
- <>
- {
- if (connection.target === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
+ ))
+ }
+ return []
+ }
+ return [
+ ,
+ ]
+ })
+ )
+ )}
+ {shouldShowDefaultHandles && }
+ >
+ )
- {shouldShowDefaultHandles && (
- {
- if (connection.target === id) return false
- const edges = useWorkflowStore.getState().edges
- return !wouldCreateCycle(edges, connection.source!, connection.target!)
- }}
- />
- )}
- >
- )}
- {hasRing && (
-
- )}
-
-
+ return (
+ {
+ if (childWorkflowId && !isDeploying && userPermissions.canAdmin) {
+ deployChildWorkflow({ workflowId: childWorkflowId })
+ }
+ }}
+ shouldShowScheduleBadge={shouldShowScheduleBadge}
+ scheduleIsDisabled={Boolean(scheduleInfo?.isDisabled)}
+ onReactivateSchedule={() => {
+ if (scheduleInfo?.id) {
+ reactivateSchedule(scheduleInfo.id)
+ }
+ }}
+ showWebhookIndicator={showWebhookIndicator}
+ webhookProvider={webhookProvider}
+ webhookPath={webhookPath}
+ webhookProviderName={webhookProviderName}
+ isWebhookConfigured={isWebhookConfigured}
+ isWebhookDisabled={isWebhookDisabled}
+ webhookId={webhookId}
+ onReactivateWebhook={() => {
+ if (webhookId) {
+ reactivateWebhook(webhookId)
+ }
+ }}
+ onSelect={handleClick}
+ contentRef={contentRef}
+ actionBar={
+ !data.isPreview && !data.isEmbedded ? (
+
+ ) : undefined
+ }
+ rows={rows}
+ />
)
}, shouldSkipBlockRender)
diff --git a/packages/workflow-renderer/src/index.ts b/packages/workflow-renderer/src/index.ts
index 482d9a4551f..0c6938dc112 100644
--- a/packages/workflow-renderer/src/index.ts
+++ b/packages/workflow-renderer/src/index.ts
@@ -7,3 +7,8 @@ export {
type SubflowNodeViewProps,
} from './subflow/subflow-node-view'
export type { BlockRunStatus, DiffStatus, EdgeDiffStatus, EdgeRunStatus } from './types'
+export { SubBlockRowView, type SubBlockRowViewProps } from './workflow-block/sub-block-row-view'
+export {
+ WorkflowBlockView,
+ type WorkflowBlockViewProps,
+} from './workflow-block/workflow-block-view'
diff --git a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx
new file mode 100644
index 00000000000..01930cdcac1
--- /dev/null
+++ b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx
@@ -0,0 +1,44 @@
+import { cn } from '@sim/emcn'
+
+/**
+ * Props for the pure subblock summary row. The container resolves the value —
+ * including all selector-name hydration (credentials, knowledge bases, tables,
+ * MCP servers/tools, sub-workflows, skills, …) — and passes only the final
+ * strings, so this view carries no store, query, or registry coupling.
+ */
+export interface SubBlockRowViewProps {
+ /** Subblock label, rendered capitalized on the left. */
+ title: string
+ /** Resolved display value on the right; `undefined` hides the value span. */
+ displayValue?: string
+ /** Render the value in a monospace font (e.g. filter expressions). */
+ isMonospace?: boolean
+}
+
+/**
+ * Pure renderer for a collapsed block's subblock summary row: a capitalized
+ * title and its resolved display value.
+ */
+export function SubBlockRowView({ title, displayValue, isMonospace }: SubBlockRowViewProps) {
+ return (
+
+
+ {title}
+
+ {displayValue !== undefined && (
+
+ {displayValue}
+
+ )}
+
+ )
+}
diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx
new file mode 100644
index 00000000000..e56254322bb
--- /dev/null
+++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx
@@ -0,0 +1,465 @@
+import type { ComponentType, ReactNode, Ref } from 'react'
+import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
+import { Handle, Position } from 'reactflow'
+import { HANDLE_POSITIONS } from '../dimensions'
+import type { BlockRunStatus } from '../types'
+
+/**
+ * Reusable styles and positioning for Handle components.
+ */
+const getHandleClasses = (position: 'left' | 'right' | 'top' | 'bottom', isError = false) => {
+ const baseClasses = '!z-[0] !cursor-crosshair !border-none !transition-[colors] !duration-150'
+ const colorClasses = isError ? '!bg-[var(--text-error)]' : '!bg-[var(--workflow-edge)]'
+
+ const positionClasses = {
+ left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover-hover:!left-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-l-full',
+ right:
+ '!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover-hover:!right-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-r-full',
+ top: '!top-[-8px] !h-[7px] !w-5 !rounded-t-[2px] !rounded-b-none hover-hover:!top-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-t-full',
+ bottom:
+ '!bottom-[-8px] !h-[7px] !w-5 !rounded-b-[2px] !rounded-t-none hover-hover:!bottom-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-b-full',
+ }
+
+ return cn(baseClasses, colorClasses, positionClasses[position])
+}
+
+const getHandleStyle = (position: 'horizontal' | 'vertical') => {
+ if (position === 'horizontal') {
+ return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
+ }
+ return { left: '50%', transform: 'translateX(-50%)' }
+}
+
+/**
+ * Props for the pure workflow-block renderer.
+ *
+ * Presentation comes from the editor (or docs) container: visual flags
+ * (enabled/locked/pending/ring), handle topology (condition/router rows), and
+ * the resolved badge state (child-deploy, schedule, webhook) are all computed
+ * upstream and passed in. The block icon, content rows, and editor-only action
+ * bar are injected as slots so the pure renderer carries no store, query, or
+ * registry coupling.
+ */
+export interface WorkflowBlockViewProps {
+ /** Block identity and visual state, resolved by the container. */
+ id: string
+ type: string
+ name: string
+ isPending?: boolean
+ isEnabled: boolean
+ isLocked: boolean
+ hasRing: boolean
+ ringStyles: string
+ /** Resolved run-path outcome, drives the muted-name styling. */
+ runPathStatus?: BlockRunStatus
+ /** Block icon component and its background color. */
+ Icon: ComponentType<{ className?: string }>
+ iconBgColor: string
+
+ /** Handle orientation and topology, resolved by the container. */
+ horizontalHandles: boolean
+ shouldShowDefaultHandles: boolean
+ hasContentBelowHeader: boolean
+ conditionRows: { id: string; title: string; value: string }[]
+ routerRows: { id: string; value: string }[]
+ /** Connection-cycle guard; reads fresh edge state on every call. */
+ wouldCreateConnectionCycle: (source: string, target: string) => boolean
+
+ /** Child-workflow deploy badge state. */
+ isWorkflowSelector: boolean
+ childWorkflowId?: string
+ childIsDeployed: boolean | null
+ childNeedsRedeploy: boolean
+ isDeploying: boolean
+ canAdmin: boolean
+ onDeployChild: () => void
+
+ /** Schedule badge state. */
+ shouldShowScheduleBadge: boolean
+ scheduleIsDisabled: boolean
+ onReactivateSchedule: () => void
+
+ /** Webhook badge state. */
+ showWebhookIndicator: boolean
+ webhookProvider?: string
+ webhookPath?: string
+ webhookProviderName?: string
+ isWebhookConfigured: boolean
+ isWebhookDisabled: boolean
+ webhookId?: string
+ onReactivateWebhook: () => void
+
+ /** Selects this block in the editor panel. */
+ onSelect: () => void
+ /** Ref attached to the inner content container. */
+ contentRef?: Ref
+ /** Editor-only action bar; omit in read-only / preview contexts. */
+ actionBar?: ReactNode
+ /** Collapsed subblock summary rows, built by the container. */
+ rows: ReactNode
+}
+
+/**
+ * Pure renderer for a workflow block: a header (icon, name, status badges), an
+ * optional content section of collapsed subblock rows, and the full handle
+ * topology (default/condition/router/error connection handles).
+ */
+export function WorkflowBlockView({
+ id,
+ type,
+ name,
+ isPending,
+ isEnabled,
+ isLocked,
+ hasRing,
+ ringStyles,
+ runPathStatus,
+ Icon,
+ iconBgColor,
+ horizontalHandles,
+ shouldShowDefaultHandles,
+ hasContentBelowHeader,
+ conditionRows,
+ routerRows,
+ wouldCreateConnectionCycle,
+ isWorkflowSelector,
+ childWorkflowId,
+ childIsDeployed,
+ childNeedsRedeploy,
+ isDeploying,
+ canAdmin,
+ onDeployChild,
+ shouldShowScheduleBadge,
+ scheduleIsDisabled,
+ onReactivateSchedule,
+ showWebhookIndicator,
+ webhookProvider,
+ webhookPath,
+ webhookProviderName,
+ isWebhookConfigured,
+ isWebhookDisabled,
+ webhookId,
+ onReactivateWebhook,
+ onSelect,
+ contentRef,
+ actionBar,
+ rows,
+}: WorkflowBlockViewProps) {
+ return (
+
+
handleKeyboardActivation(event, onSelect)}
+ className={cn(
+ 'workflow-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
+ )}
+ >
+ {isPending && (
+
+ Next Step
+
+ )}
+
+ {actionBar}
+
+ {shouldShowDefaultHandles && (
+
{
+ if (connection.source === id) return false
+ return !wouldCreateConnectionCycle(connection.source!, connection.target!)
+ }}
+ />
+ )}
+
+
+
+
+ {isWorkflowSelector &&
+ childWorkflowId &&
+ typeof childIsDeployed === 'boolean' &&
+ (!childIsDeployed || childNeedsRedeploy) && (
+
+
+ {
+ e.stopPropagation()
+ onDeployChild()
+ }}
+ >
+ {isDeploying ? 'Deploying...' : !childIsDeployed ? 'undeployed' : 'redeploy'}
+
+
+
+
+ {!canAdmin
+ ? 'Admin permission required to deploy'
+ : !childIsDeployed
+ ? 'Click to deploy'
+ : 'Click to redeploy'}
+
+
+
+ )}
+ {!isEnabled && !isLocked &&
disabled}
+ {isLocked &&
locked}
+
+ {type === 'schedule' && shouldShowScheduleBadge && scheduleIsDisabled && (
+
+
+ {
+ e.stopPropagation()
+ onReactivateSchedule()
+ }}
+ >
+ disabled
+
+
+
+ Click to reactivate
+
+
+ )}
+
+ {showWebhookIndicator && (
+
+
+
+ Webhook
+
+
+
+ {webhookProvider && webhookPath ? (
+ <>
+ {webhookProviderName} Webhook
+ Path: {webhookPath}
+ >
+ ) : (
+
+ This workflow is triggered by a webhook.
+
+ )}
+
+
+ )}
+
+ {isWebhookConfigured && isWebhookDisabled && webhookId && (
+
+
+ {
+ e.stopPropagation()
+ onReactivateWebhook()
+ }}
+ >
+ disabled
+
+
+
+ Click to reactivate
+
+
+ )}
+ {/* {isActive && (
+
+ )} */}
+
+
+
+ {hasContentBelowHeader && {rows}
}
+
+ {type === 'condition' && (
+ <>
+ {conditionRows.map((cond, condIndex) => {
+ const topOffset =
+ HANDLE_POSITIONS.CONDITION_START_Y +
+ condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
+ return (
+ {
+ if (connection.target === id) return false
+ return !wouldCreateConnectionCycle(connection.source!, connection.target!)
+ }}
+ />
+ )
+ })}
+ {
+ if (connection.target === id) return false
+ return !wouldCreateConnectionCycle(connection.source!, connection.target!)
+ }}
+ />
+ >
+ )}
+
+ {type === 'router_v2' && (
+ <>
+ {routerRows.map((route, routeIndex) => {
+ // +1 row offset for context row at the top
+ const topOffset =
+ HANDLE_POSITIONS.CONDITION_START_Y +
+ (routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
+ return (
+ {
+ if (connection.target === id) return false
+ return !wouldCreateConnectionCycle(connection.source!, connection.target!)
+ }}
+ />
+ )
+ })}
+ {
+ if (connection.target === id) return false
+ return !wouldCreateConnectionCycle(connection.source!, connection.target!)
+ }}
+ />
+ >
+ )}
+
+ {type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
+ <>
+ {
+ if (connection.target === id) return false
+ return !wouldCreateConnectionCycle(connection.source!, connection.target!)
+ }}
+ />
+
+ {shouldShowDefaultHandles && (
+ {
+ if (connection.target === id) return false
+ return !wouldCreateConnectionCycle(connection.source!, connection.target!)
+ }}
+ />
+ )}
+ >
+ )}
+ {hasRing && (
+
+ )}
+
+
+ )
+}