- {/* CSS override to show full opacity and prevent interaction instead of dimmed disabled state */}
-
+
+
{visibleSubBlocks.length > 0 ? (
{visibleSubBlocks.map((subBlockConfig, index) => (
@@ -1349,7 +1429,7 @@ function PreviewEditorContent({
)}
{/* Context Menu */}
-
- /** Skips expensive subblock computations for thumbnails */
+ /** Skips expensive subblock computations for thumbnails/template previews */
lightweight?: boolean
- /** Whether this is a subflow container (loop/parallel) */
- isSubflow?: boolean
- /** Type of subflow container */
- subflowKind?: 'loop' | 'parallel'
- /** Width of subflow container */
- width?: number
- /** Height of subflow container */
- height?: number
}
/**
@@ -166,21 +180,17 @@ function resolveToolsDisplay(
if (!tool || typeof tool !== 'object') return null
const t = tool as Record
- // Priority 1: Use tool.title if already populated
if (t.title && typeof t.title === 'string') return t.title
- // Priority 2: Extract from inline schema (legacy format)
const schema = t.schema as Record | undefined
if (schema?.function && typeof schema.function === 'object') {
const fn = schema.function as Record
if (fn.name && typeof fn.name === 'string') return fn.name
}
- // Priority 3: Extract from OpenAI function format
const fn = t.function as Record | undefined
if (fn?.name && typeof fn.name === 'string') return fn.name
- // Priority 4: Resolve built-in tool blocks from registry
if (
typeof t.type === 'string' &&
t.type !== 'custom-tool' &&
@@ -246,115 +256,11 @@ function SubBlockRow({ title, value, subBlock, rawValue }: SubBlockRowProps) {
)
}
-interface SubflowContainerProps {
- name: string
- width?: number
- height?: number
- kind: 'loop' | 'parallel'
- isPreviewSelected?: boolean
-}
-
-/**
- * Renders a subflow container (loop/parallel) for preview mode.
- */
-function SubflowContainer({
- name,
- width = 500,
- height = 300,
- kind,
- isPreviewSelected = false,
-}: SubflowContainerProps) {
- const isLoop = kind === 'loop'
- const BlockIcon = isLoop ? RepeatIcon : SplitIcon
- const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
- const blockName = name || (isLoop ? 'Loop' : 'Parallel')
-
- const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
- const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
-
- const leftHandleClass =
- '!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
- const rightHandleClass =
- '!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none'
-
- return (
-
- {/* Selection ring overlay */}
- {isPreviewSelected && (
-
- )}
-
- {/* Target handle on left (input to the subflow) */}
-
-
- {/* Header - matches actual subflow header structure */}
-
-
-
-
-
-
- {blockName}
-
-
-
-
- {/* Content area - matches workflow structure */}
-
- {/* Subflow Start - connects to first block in subflow */}
-
- Start
-
-
-
-
- {/* End source handle on right (output from the subflow) */}
-
-
- )
-}
-
/**
* Preview block component for workflow visualization.
* Renders block header, subblock values, and handles without
* hooks, store subscriptions, or interactive features.
* Matches the visual structure of WorkflowBlock exactly.
- * Also handles subflow containers (loop/parallel) when isSubflow is true.
*/
function WorkflowPreviewBlockInner({ data }: NodeProps) {
const {
@@ -367,32 +273,13 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
executionStatus,
subBlockValues,
lightweight = false,
- isSubflow = false,
- subflowKind,
- width,
- height,
} = data
- if (isSubflow && subflowKind) {
- return (
-
- )
- }
-
const blockConfig = getBlock(type)
const canonicalIndex = useMemo(
- () =>
- lightweight
- ? { groupsById: {}, canonicalIdBySubBlockId: {} }
- : buildCanonicalIndex(blockConfig?.subBlocks || []),
- [blockConfig?.subBlocks, lightweight]
+ () => buildCanonicalIndex(blockConfig?.subBlocks || []),
+ [blockConfig?.subBlocks]
)
const rawValues = useMemo(() => {
@@ -404,10 +291,9 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
}, [subBlockValues, lightweight])
const visibleSubBlocks = useMemo(() => {
- if (lightweight || !blockConfig?.subBlocks) return []
+ if (!blockConfig?.subBlocks) return []
const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers'
-
const effectiveTrigger = isTrigger || type === 'starter'
return blockConfig.subBlocks.filter((subBlock) => {
@@ -424,26 +310,40 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
if (subBlock.mode === 'trigger') return false
}
+ /** Skip value-dependent visibility checks in lightweight mode */
+ if (lightweight) return !subBlock.condition
+
if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) {
return false
}
-
if (!subBlock.condition) return true
return evaluateSubBlockCondition(subBlock.condition, rawValues)
})
}, [
lightweight,
blockConfig?.subBlocks,
- blockConfig?.category,
blockConfig?.triggers?.enabled,
+ blockConfig?.category,
type,
isTrigger,
canonicalIndex,
rawValues,
])
+ /**
+ * Compute condition rows for condition blocks.
+ * In lightweight mode, returns default structure without parsing values.
+ */
const conditionRows = useMemo(() => {
- if (lightweight || type !== 'condition') return []
+ if (type !== 'condition') return []
+
+ /** Default structure for lightweight mode or when no values */
+ const defaultRows = [
+ { id: 'if', title: 'if', value: '' },
+ { id: 'else', title: 'else', value: '' },
+ ]
+
+ if (lightweight) return defaultRows
const conditionsValue = rawValues.conditions
const raw = typeof conditionsValue === 'string' ? conditionsValue : undefined
@@ -464,17 +364,23 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
}
}
} catch {
- // Failed to parse, use fallback
+ /* empty */
}
- return [
- { id: 'if', title: 'if', value: '' },
- { id: 'else', title: 'else', value: '' },
- ]
- }, [lightweight, type, rawValues])
+ return defaultRows
+ }, [type, rawValues, lightweight])
+ /**
+ * Compute router rows for router_v2 blocks.
+ * In lightweight mode, returns default structure without parsing values.
+ */
const routerRows = useMemo(() => {
- if (lightweight || type !== 'router_v2') return []
+ if (type !== 'router_v2') return []
+
+ /** Default structure for lightweight mode or when no values */
+ const defaultRows = [{ id: 'route1', value: '' }]
+
+ if (lightweight) return defaultRows
const routesValue = rawValues.routes
const raw = typeof routesValue === 'string' ? routesValue : undefined
@@ -493,11 +399,11 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
}
}
} catch {
- // Failed to parse, use fallback
+ /* empty */
}
- return [{ id: 'route1', value: '' }]
- }, [lightweight, type, rawValues])
+ return defaultRows
+ }, [type, rawValues, lightweight])
if (!blockConfig) {
return null
@@ -515,9 +421,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
? routerRows.length > 0 || shouldShowDefaultHandles
: hasSubBlocks || shouldShowDefaultHandles
- const horizontalHandleClass = '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-[2px]'
- const verticalHandleClass = '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-[2px]'
-
const hasError = executionStatus === 'error'
const hasSuccess = executionStatus === 'success'
@@ -542,7 +445,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
type='target'
position={horizontalHandles ? Position.Left : Position.Top}
id='target'
- className={horizontalHandles ? horizontalHandleClass : verticalHandleClass}
+ className={horizontalHandles ? HANDLE_STYLES.horizontal : HANDLE_STYLES.vertical}
style={
horizontalHandles
? { left: '-7px', top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px` }
@@ -576,32 +479,36 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
{type === 'condition' ? (
conditionRows.map((cond) => (
-
+
))
) : type === 'router_v2' ? (
<>
{routerRows.map((route, index) => (
))}
>
) : (
visibleSubBlocks.map((subBlock) => {
- const rawValue = rawValues[subBlock.id]
+ const rawValue = lightweight ? undefined : rawValues[subBlock.id]
return (
)
@@ -612,7 +519,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
)}
- {/* Condition block handles - one per condition branch + error */}
+ {/* Condition block handles */}
{type === 'condition' && (
<>
{conditionRows.map((cond, condIndex) => {
@@ -624,8 +531,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
type='source'
position={Position.Right}
id={`condition-${cond.id}`}
- className={horizontalHandleClass}
- style={{ right: '-7px', top: `${topOffset}px`, transform: 'translateY(-50%)' }}
+ className={HANDLE_STYLES.right}
+ style={{ top: `${topOffset}px`, right: '-7px', transform: 'translateY(-50%)' }}
/>
)
})}
@@ -633,22 +540,16 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
type='source'
position={Position.Right}
id='error'
- className='!border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-[2px]'
- style={{
- right: '-7px',
- top: 'auto',
- bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
- transform: 'translateY(50%)',
- }}
+ className={HANDLE_STYLES.error}
+ style={ERROR_HANDLE_STYLE}
/>
>
)}
- {/* Router block handles - one per route + error */}
+ {/* Router block handles */}
{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
@@ -658,8 +559,8 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
type='source'
position={Position.Right}
id={`router-${route.id}`}
- className={horizontalHandleClass}
- style={{ right: '-7px', top: `${topOffset}px`, transform: 'translateY(-50%)' }}
+ className={HANDLE_STYLES.right}
+ style={{ top: `${topOffset}px`, right: '-7px', transform: 'translateY(-50%)' }}
/>
)
})}
@@ -667,25 +568,20 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
type='source'
position={Position.Right}
id='error'
- className='!border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-[2px]'
- style={{
- right: '-7px',
- top: 'auto',
- bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
- transform: 'translateY(50%)',
- }}
+ className={HANDLE_STYLES.error}
+ style={ERROR_HANDLE_STYLE}
/>
>
)}
- {/* Standard block handles - source + error (not for condition, router, or response) */}
+ {/* Source and error handles for non-condition/router blocks */}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
<>
type='source'
position={Position.Right}
id='error'
- className='!border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-[2px]'
- style={{
- right: '-7px',
- top: 'auto',
- bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
- transform: 'translateY(50%)',
- }}
+ className={HANDLE_STYLES.error}
+ style={ERROR_HANDLE_STYLE}
/>
)}
>
@@ -712,6 +603,13 @@ function WorkflowPreviewBlockInner({ data }: NodeProps
)
}
+/**
+ * Custom comparison function for React.memo optimization.
+ * Uses fast-path primitive comparison before shallow comparing subBlockValues.
+ * @param prevProps - Previous render props
+ * @param nextProps - Next render props
+ * @returns True if render should be skipped (props are equal)
+ */
function shouldSkipPreviewBlockRender(
prevProps: NodeProps,
nextProps: NodeProps
@@ -725,40 +623,40 @@ function shouldSkipPreviewBlockRender(
prevProps.data.enabled !== nextProps.data.enabled ||
prevProps.data.isPreviewSelected !== nextProps.data.isPreviewSelected ||
prevProps.data.executionStatus !== nextProps.data.executionStatus ||
- prevProps.data.lightweight !== nextProps.data.lightweight ||
- prevProps.data.isSubflow !== nextProps.data.isSubflow ||
- prevProps.data.subflowKind !== nextProps.data.subflowKind ||
- prevProps.data.width !== nextProps.data.width ||
- prevProps.data.height !== nextProps.data.height
+ prevProps.data.lightweight !== nextProps.data.lightweight
) {
return false
}
+ /** Skip subBlockValues comparison in lightweight mode */
+ if (nextProps.data.lightweight) return true
+
const prevValues = prevProps.data.subBlockValues
const nextValues = nextProps.data.subBlockValues
- if (prevValues === nextValues) {
- return true
- }
-
- if (!prevValues || !nextValues) {
- return false
- }
+ if (prevValues === nextValues) return true
+ if (!prevValues || !nextValues) return false
const prevKeys = Object.keys(prevValues)
const nextKeys = Object.keys(nextValues)
- if (prevKeys.length !== nextKeys.length) {
- return false
- }
+ if (prevKeys.length !== nextKeys.length) return false
for (const key of prevKeys) {
- if (prevValues[key] !== nextValues[key]) {
- return false
- }
+ if (prevValues[key] !== nextValues[key]) return false
}
return true
}
-export const WorkflowPreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender)
+/**
+ * Preview block component for workflow visualization in readonly contexts.
+ * Optimized for rendering without hooks or store subscriptions.
+ *
+ * @remarks
+ * - Renders block header, subblock values, and connection handles
+ * - Supports condition, router, and standard block types
+ * - Shows error handles for non-trigger blocks
+ * - Displays execution status via colored ring overlays
+ */
+export const PreviewBlock = memo(WorkflowPreviewBlockInner, shouldSkipPreviewBlockRender)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/index.ts
new file mode 100644
index 000000000..bd6475e11
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/index.ts
@@ -0,0 +1 @@
+export { PreviewBlock } from './block'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/index.ts
new file mode 100644
index 000000000..ee99f14c6
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/index.ts
@@ -0,0 +1 @@
+export { PreviewSubflow } from './subflow'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx
new file mode 100644
index 000000000..848e93c7f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx
@@ -0,0 +1,131 @@
+'use client'
+
+import { memo } from 'react'
+import { RepeatIcon, SplitIcon } from 'lucide-react'
+import { Handle, type NodeProps, Position } from 'reactflow'
+import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
+
+/** Execution status for subflows in preview mode */
+type ExecutionStatus = 'success' | 'error' | 'not-executed'
+
+interface WorkflowPreviewSubflowData {
+ name: string
+ width?: number
+ height?: number
+ kind: 'loop' | 'parallel'
+ /** Whether this subflow is selected in preview mode */
+ isPreviewSelected?: boolean
+ /** Execution status for highlighting the subflow container */
+ executionStatus?: ExecutionStatus
+ /** Skips expensive computations for thumbnails/template previews (unused in subflow, for consistency) */
+ lightweight?: boolean
+}
+
+/**
+ * Preview subflow component for workflow visualization.
+ * Renders loop/parallel containers without hooks, store subscriptions,
+ * or interactive features.
+ */
+function WorkflowPreviewSubflowInner({ data }: NodeProps) {
+ const { name, width = 500, height = 300, kind, isPreviewSelected = false, executionStatus } = data
+
+ const isLoop = kind === 'loop'
+ const BlockIcon = isLoop ? RepeatIcon : SplitIcon
+ const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B'
+ const blockName = name || (isLoop ? 'Loop' : 'Parallel')
+
+ const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source'
+ const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source'
+
+ const leftHandleClass =
+ '!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none'
+ const rightHandleClass =
+ '!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none'
+
+ const hasError = executionStatus === 'error'
+ const hasSuccess = executionStatus === 'success'
+
+ return (
+
+ {/* Selection ring overlay (takes priority over execution rings) */}
+ {isPreviewSelected && (
+
+ )}
+ {/* Success ring overlay (only shown if not selected) */}
+ {!isPreviewSelected && hasSuccess && (
+
+ )}
+ {/* Error ring overlay (only shown if not selected) */}
+ {!isPreviewSelected && hasError && (
+
+ )}
+
+ {/* Target handle on left (input to the subflow) */}
+
+
+ {/* Header - matches actual subflow header structure */}
+
+
+
+
+
+
+ {blockName}
+
+
+
+
+ {/* Content area - matches workflow structure */}
+
+ {/* Subflow Start - connects to first block in subflow */}
+
+ Start
+
+
+
+
+ {/* End source handle on right (output from the subflow) */}
+
+
+ )
+}
+
+export const PreviewSubflow = memo(WorkflowPreviewSubflowInner)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/index.ts
new file mode 100644
index 000000000..cd15990bf
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/index.ts
@@ -0,0 +1 @@
+export { getLeftmostBlockId, PreviewWorkflow } from './preview-workflow'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx
new file mode 100644
index 000000000..d77363245
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx
@@ -0,0 +1,613 @@
+'use client'
+
+import { useEffect, useMemo, useRef } from 'react'
+import ReactFlow, {
+ ConnectionLineType,
+ type Edge,
+ type EdgeTypes,
+ type Node,
+ type NodeTypes,
+ ReactFlowProvider,
+ useReactFlow,
+} from 'reactflow'
+import 'reactflow/dist/style.css'
+
+import { createLogger } from '@sim/logger'
+import { cn } from '@/lib/core/utils/cn'
+import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
+import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
+import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
+import { PreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block'
+import { PreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow'
+import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
+
+const logger = createLogger('PreviewWorkflow')
+
+/**
+ * Gets block dimensions for preview purposes.
+ * For containers, uses stored dimensions or defaults.
+ * For regular blocks, uses stored height or estimates based on type.
+ */
+function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } {
+ if (block.type === 'loop' || block.type === 'parallel') {
+ return {
+ width: block.data?.width
+ ? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH)
+ : CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
+ height: block.data?.height
+ ? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT)
+ : CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
+ }
+ }
+
+ if (block.height) {
+ return {
+ width: BLOCK_DIMENSIONS.FIXED_WIDTH,
+ height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
+ }
+ }
+
+ return estimateBlockDimensions(block.type)
+}
+
+/**
+ * Calculates container dimensions based on child block positions and sizes.
+ * Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
+ */
+function calculateContainerDimensions(
+ containerId: string,
+ blocks: Record
+): { width: number; height: number } {
+ const childBlocks = Object.values(blocks).filter((block) => block?.data?.parentId === containerId)
+
+ if (childBlocks.length === 0) {
+ return {
+ width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
+ height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
+ }
+ }
+
+ let maxRight = 0
+ let maxBottom = 0
+
+ for (const child of childBlocks) {
+ if (!child?.position) continue
+
+ const { width: childWidth, height: childHeight } = getPreviewBlockDimensions(child)
+
+ maxRight = Math.max(maxRight, child.position.x + childWidth)
+ maxBottom = Math.max(maxBottom, child.position.y + childHeight)
+ }
+
+ const width = Math.max(
+ CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
+ maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
+ )
+ const height = Math.max(
+ CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
+ maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
+ )
+
+ return { width, height }
+}
+
+/**
+ * Finds the leftmost block ID from a workflow state.
+ * Excludes subflow containers (loop/parallel) from consideration.
+ * @param workflowState - The workflow state to search
+ * @returns The ID of the leftmost block, or null if no blocks exist
+ */
+export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
+ if (!workflowState?.blocks) return null
+
+ let leftmostId: string | null = null
+ let minX = Number.POSITIVE_INFINITY
+
+ for (const [blockId, block] of Object.entries(workflowState.blocks)) {
+ if (!block || block.type === 'loop' || block.type === 'parallel') continue
+ const x = block.position?.x ?? Number.POSITIVE_INFINITY
+ if (x < minX) {
+ minX = x
+ leftmostId = blockId
+ }
+ }
+
+ return leftmostId
+}
+
+/** Execution status for edges/nodes in the preview */
+type ExecutionStatus = 'success' | 'error' | 'not-executed'
+
+/** Calculates absolute position for blocks, handling nested subflows */
+function calculateAbsolutePosition(
+ block: BlockState,
+ blocks: Record
+): { x: number; y: number } {
+ if (!block.data?.parentId) {
+ return block.position
+ }
+
+ const parentBlock = blocks[block.data.parentId]
+ if (!parentBlock) {
+ logger.warn(`Parent block not found for child block`)
+ return block.position
+ }
+
+ const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
+ return {
+ x: parentAbsolutePosition.x + block.position.x,
+ y: parentAbsolutePosition.y + block.position.y,
+ }
+}
+
+interface PreviewWorkflowProps {
+ workflowState: WorkflowState
+ className?: string
+ height?: string | number
+ width?: string | number
+ isPannable?: boolean
+ defaultPosition?: { x: number; y: number }
+ defaultZoom?: number
+ fitPadding?: number
+ onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void
+ /** Callback when a node is right-clicked */
+ onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
+ /** Callback when the canvas (empty area) is clicked */
+ onPaneClick?: () => void
+ /** Cursor style to show when hovering the canvas */
+ cursorStyle?: 'default' | 'pointer' | 'grab'
+ /** Map of executed block IDs to their status for highlighting the execution path */
+ executedBlocks?: Record
+ /** Currently selected block ID for highlighting */
+ selectedBlockId?: string | null
+ /** Skips expensive subblock computations for thumbnails/template previews */
+ lightweight?: boolean
+}
+
+/**
+ * Preview node types using minimal components without hooks or store subscriptions.
+ * This prevents interaction issues while allowing canvas panning and node clicking.
+ */
+const previewNodeTypes: NodeTypes = {
+ workflowBlock: PreviewBlock,
+ noteBlock: PreviewBlock,
+ subflowNode: PreviewSubflow,
+}
+
+const edgeTypes: EdgeTypes = {
+ default: WorkflowEdge,
+ workflowEdge: WorkflowEdge,
+}
+
+interface FitViewOnChangeProps {
+ nodeIds: string
+ fitPadding: number
+ containerRef: React.RefObject
+}
+
+/**
+ * Helper component that calls fitView when the set of nodes changes or when the container resizes.
+ * Only triggers on actual node additions/removals, not on selection changes.
+ * Must be rendered inside ReactFlowProvider.
+ */
+function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
+ const { fitView } = useReactFlow()
+ const lastNodeIdsRef = useRef(null)
+
+ useEffect(() => {
+ if (!nodeIds.length) return
+ const shouldFit = lastNodeIdsRef.current !== nodeIds
+ if (!shouldFit) return
+ lastNodeIdsRef.current = nodeIds
+
+ const timeoutId = setTimeout(() => {
+ fitView({ padding: fitPadding, duration: 200 })
+ }, 50)
+ return () => clearTimeout(timeoutId)
+ }, [nodeIds, fitPadding, fitView])
+
+ useEffect(() => {
+ const container = containerRef.current
+ if (!container) return
+
+ let timeoutId: ReturnType | null = null
+
+ const resizeObserver = new ResizeObserver(() => {
+ if (timeoutId) clearTimeout(timeoutId)
+ timeoutId = setTimeout(() => {
+ fitView({ padding: fitPadding, duration: 150 })
+ }, 100)
+ })
+
+ resizeObserver.observe(container)
+ return () => {
+ if (timeoutId) clearTimeout(timeoutId)
+ resizeObserver.disconnect()
+ }
+ }, [containerRef, fitPadding, fitView])
+
+ return null
+}
+
+/**
+ * Readonly workflow component for visualizing workflow state.
+ * Renders blocks, subflows, and edges with execution status highlighting.
+ *
+ * @remarks
+ * - Supports panning and node click interactions
+ * - Shows execution path via green edges for successful paths
+ * - Error edges display red by default, green when error path was taken
+ * - Fits view automatically when nodes change or container resizes
+ */
+export function PreviewWorkflow({
+ workflowState,
+ className,
+ height = '100%',
+ width = '100%',
+ isPannable = true,
+ defaultPosition,
+ defaultZoom = 0.8,
+ fitPadding = 0.25,
+ onNodeClick,
+ onNodeContextMenu,
+ onPaneClick,
+ cursorStyle = 'grab',
+ executedBlocks,
+ selectedBlockId,
+ lightweight = false,
+}: PreviewWorkflowProps) {
+ const containerRef = useRef(null)
+ const nodeTypes = previewNodeTypes
+ const isValidWorkflowState = workflowState?.blocks && workflowState.edges
+
+ const blocksStructure = useMemo(() => {
+ if (!isValidWorkflowState) return { count: 0, ids: '' }
+ return {
+ count: Object.keys(workflowState.blocks || {}).length,
+ ids: Object.keys(workflowState.blocks || {}).join(','),
+ }
+ }, [workflowState.blocks, isValidWorkflowState])
+
+ const loopsStructure = useMemo(() => {
+ if (!isValidWorkflowState) return { count: 0, ids: '' }
+ return {
+ count: Object.keys(workflowState.loops || {}).length,
+ ids: Object.keys(workflowState.loops || {}).join(','),
+ }
+ }, [workflowState.loops, isValidWorkflowState])
+
+ const parallelsStructure = useMemo(() => {
+ if (!isValidWorkflowState) return { count: 0, ids: '' }
+ return {
+ count: Object.keys(workflowState.parallels || {}).length,
+ ids: Object.keys(workflowState.parallels || {}).join(','),
+ }
+ }, [workflowState.parallels, isValidWorkflowState])
+
+ /** Map of subflow ID to child block IDs */
+ const subflowChildrenMap = useMemo(() => {
+ if (!isValidWorkflowState) return new Map()
+
+ const map = new Map()
+ for (const [blockId, block] of Object.entries(workflowState.blocks || {})) {
+ const parentId = block?.data?.parentId
+ if (parentId) {
+ const children = map.get(parentId) || []
+ children.push(blockId)
+ map.set(parentId, children)
+ }
+ }
+ return map
+ }, [workflowState.blocks, isValidWorkflowState])
+
+ /** Derives subflow execution status from child blocks */
+ const getSubflowExecutionStatus = useMemo(() => {
+ return (subflowId: string): ExecutionStatus | undefined => {
+ if (!executedBlocks) return undefined
+
+ const childIds = subflowChildrenMap.get(subflowId)
+ if (!childIds?.length) return undefined
+
+ const childStatuses = childIds.map((id) => executedBlocks[id]).filter(Boolean)
+ if (childStatuses.length === 0) return undefined
+
+ if (childStatuses.some((s) => s.status === 'error')) return 'error'
+ if (childStatuses.some((s) => s.status === 'success')) return 'success'
+ return 'not-executed'
+ }
+ }, [executedBlocks, subflowChildrenMap])
+
+ /** Gets execution status for any block, deriving subflow status from children */
+ const getBlockExecutionStatus = useMemo(() => {
+ return (blockId: string): { status: string; executed: boolean } | undefined => {
+ if (!executedBlocks) return undefined
+
+ const directStatus = executedBlocks[blockId]
+ if (directStatus) {
+ return { status: directStatus.status, executed: true }
+ }
+
+ const block = workflowState.blocks?.[blockId]
+ if (block && (block.type === 'loop' || block.type === 'parallel')) {
+ const subflowStatus = getSubflowExecutionStatus(blockId)
+ if (subflowStatus) {
+ return { status: subflowStatus, executed: true }
+ }
+
+ const incomingEdge = workflowState.edges?.find((e) => e.target === blockId)
+ if (incomingEdge && executedBlocks[incomingEdge.source]?.status === 'success') {
+ return { status: 'not-executed', executed: true }
+ }
+ }
+
+ return undefined
+ }
+ }, [executedBlocks, workflowState.blocks, workflowState.edges, getSubflowExecutionStatus])
+
+ const edgesStructure = useMemo(() => {
+ if (!isValidWorkflowState) return { count: 0, ids: '' }
+ return {
+ count: workflowState.edges?.length || 0,
+ ids: workflowState.edges?.map((e) => e.id).join(',') || '',
+ }
+ }, [workflowState.edges, isValidWorkflowState])
+
+ const nodes: Node[] = useMemo(() => {
+ if (!isValidWorkflowState) return []
+
+ const nodeArray: Node[] = []
+
+ Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
+ if (!block || !block.type) {
+ logger.warn(`Skipping invalid block: ${blockId}`)
+ return
+ }
+
+ const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
+
+ if (block.type === 'loop' || block.type === 'parallel') {
+ const isSelected = selectedBlockId === blockId
+ const dimensions = calculateContainerDimensions(blockId, workflowState.blocks)
+ const subflowExecutionStatus = getSubflowExecutionStatus(blockId)
+
+ nodeArray.push({
+ id: blockId,
+ type: 'subflowNode',
+ position: absolutePosition,
+ draggable: false,
+ data: {
+ name: block.name,
+ width: dimensions.width,
+ height: dimensions.height,
+ kind: block.type as 'loop' | 'parallel',
+ isPreviewSelected: isSelected,
+ executionStatus: subflowExecutionStatus,
+ lightweight,
+ },
+ })
+ return
+ }
+
+ const isSelected = selectedBlockId === blockId
+
+ let executionStatus: ExecutionStatus | undefined
+ if (executedBlocks) {
+ const blockExecution = executedBlocks[blockId]
+ if (blockExecution) {
+ if (blockExecution.status === 'error') {
+ executionStatus = 'error'
+ } else if (blockExecution.status === 'success') {
+ executionStatus = 'success'
+ } else {
+ executionStatus = 'not-executed'
+ }
+ } else {
+ executionStatus = 'not-executed'
+ }
+ }
+
+ nodeArray.push({
+ id: blockId,
+ type: 'workflowBlock',
+ position: absolutePosition,
+ draggable: false,
+ zIndex: block.data?.parentId ? 10 : undefined,
+ data: {
+ type: block.type,
+ name: block.name,
+ isTrigger: block.triggerMode === true,
+ horizontalHandles: block.horizontalHandles ?? false,
+ enabled: block.enabled ?? true,
+ isPreviewSelected: isSelected,
+ executionStatus,
+ subBlockValues: block.subBlocks,
+ lightweight,
+ },
+ })
+ })
+
+ return nodeArray
+ }, [
+ blocksStructure,
+ loopsStructure,
+ parallelsStructure,
+ workflowState.blocks,
+ isValidWorkflowState,
+ executedBlocks,
+ selectedBlockId,
+ getSubflowExecutionStatus,
+ lightweight,
+ ])
+
+ const edges: Edge[] = useMemo(() => {
+ if (!isValidWorkflowState) return []
+
+ /**
+ * Determines edge execution status for visualization.
+ * Error edges turn green when taken (source errored, target executed).
+ * Normal edges turn green when both source succeeded and target executed.
+ */
+ const getEdgeExecutionStatus = (edge: {
+ source: string
+ target: string
+ sourceHandle?: string | null
+ }): ExecutionStatus | undefined => {
+ if (!executedBlocks) return undefined
+
+ const sourceStatus = getBlockExecutionStatus(edge.source)
+ const targetStatus = getBlockExecutionStatus(edge.target)
+ const isErrorEdge = edge.sourceHandle === 'error'
+
+ if (isErrorEdge) {
+ return sourceStatus?.status === 'error' && targetStatus?.executed
+ ? 'success'
+ : 'not-executed'
+ }
+
+ const isSubflowStartEdge =
+ edge.sourceHandle === 'loop-start-source' || edge.sourceHandle === 'parallel-start-source'
+
+ if (isSubflowStartEdge) {
+ const incomingEdge = workflowState.edges?.find((e) => e.target === edge.source)
+ const incomingSucceeded = incomingEdge
+ ? executedBlocks[incomingEdge.source]?.status === 'success'
+ : false
+ return incomingSucceeded ? 'success' : 'not-executed'
+ }
+
+ const targetBlock = workflowState.blocks?.[edge.target]
+ const targetIsSubflow =
+ targetBlock && (targetBlock.type === 'loop' || targetBlock.type === 'parallel')
+
+ if (sourceStatus?.status === 'success' && (targetStatus?.executed || targetIsSubflow)) {
+ return 'success'
+ }
+
+ return 'not-executed'
+ }
+
+ return (workflowState.edges || []).map((edge) => {
+ const status = getEdgeExecutionStatus(edge)
+ const isErrorEdge = edge.sourceHandle === 'error'
+ return {
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ sourceHandle: edge.sourceHandle,
+ targetHandle: edge.targetHandle,
+ data: {
+ ...(status ? { executionStatus: status } : {}),
+ sourceHandle: edge.sourceHandle,
+ },
+ zIndex: status === 'success' ? 10 : isErrorEdge ? 5 : 0,
+ }
+ })
+ }, [
+ edgesStructure,
+ workflowState.edges,
+ workflowState.blocks,
+ isValidWorkflowState,
+ executedBlocks,
+ getBlockExecutionStatus,
+ ])
+
+ if (!isValidWorkflowState) {
+ return (
+
+
+
⚠️ Logged State Not Found
+
+ This log was migrated from the old system and doesn't contain workflow state data.
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {
+ logger.debug('Node clicked:', { nodeId: node.id, event })
+ onNodeClick(node.id, { x: event.clientX, y: event.clientY })
+ }
+ : undefined
+ }
+ onNodeContextMenu={
+ onNodeContextMenu
+ ? (event, node) => {
+ event.preventDefault()
+ event.stopPropagation()
+ onNodeContextMenu(node.id, { x: event.clientX, y: event.clientY })
+ }
+ : undefined
+ }
+ onPaneClick={onPaneClick}
+ />
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts
index d64f8979c..2abd6a9be 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts
@@ -1,2 +1,6 @@
+export { PreviewContextMenu } from './components/preview-context-menu'
export { PreviewEditor } from './components/preview-editor'
-export { getLeftmostBlockId, WorkflowPreview } from './preview'
+export { getLeftmostBlockId, PreviewWorkflow } from './components/preview-workflow'
+export { PreviewBlock } from './components/preview-workflow/components/block'
+export { PreviewSubflow } from './components/preview-workflow/components/subflow'
+export { buildBlockExecutions, Preview } from './preview'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx
index ec00e2c38..8780c6317 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx
@@ -1,493 +1,292 @@
'use client'
-import { useEffect, useMemo, useRef } from 'react'
-import ReactFlow, {
- ConnectionLineType,
- type Edge,
- type EdgeTypes,
- type Node,
- type NodeTypes,
- ReactFlowProvider,
- useReactFlow,
-} from 'reactflow'
-import 'reactflow/dist/style.css'
-
-import { createLogger } from '@sim/logger'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { ArrowLeft } from 'lucide-react'
+import { Button, Tooltip } from '@/components/emcn'
+import { redactApiKeys } from '@/lib/core/security/redaction'
import { cn } from '@/lib/core/utils/cn'
-import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
-import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
-import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
-import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block'
-import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
+import { PreviewEditor } from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-editor'
+import {
+ getLeftmostBlockId,
+ PreviewWorkflow,
+} from '@/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow'
+import type { WorkflowState } from '@/stores/workflows/workflow/types'
-const logger = createLogger('WorkflowPreview')
-
-/**
- * Gets block dimensions for preview purposes.
- * For containers, uses stored dimensions or defaults.
- * For regular blocks, uses stored height or estimates based on type.
- */
-function getPreviewBlockDimensions(block: BlockState): { width: number; height: number } {
- if (block.type === 'loop' || block.type === 'parallel') {
- return {
- width: block.data?.width
- ? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH)
- : CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
- height: block.data?.height
- ? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT)
- : CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
- }
- }
-
- if (block.height) {
- return {
- width: BLOCK_DIMENSIONS.FIXED_WIDTH,
- height: Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT),
- }
- }
-
- return estimateBlockDimensions(block.type)
+interface TraceSpan {
+ blockId?: string
+ input?: unknown
+ output?: unknown
+ status?: string
+ duration?: number
+ children?: TraceSpan[]
}
-/**
- * Calculates container dimensions based on child block positions and sizes.
- * Mirrors the logic from useNodeUtilities.calculateLoopDimensions.
- * Accepts pre-filtered childBlocks for O(1) lookup instead of filtering all blocks.
- */
-function calculateContainerDimensions(childBlocks: BlockState[]): {
- width: number
- height: number
-} {
- if (childBlocks.length === 0) {
- return {
- width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
- height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
- }
- }
-
- let maxRight = 0
- let maxBottom = 0
-
- for (const child of childBlocks) {
- if (!child?.position) continue
-
- const { width: childWidth, height: childHeight } = getPreviewBlockDimensions(child)
-
- maxRight = Math.max(maxRight, child.position.x + childWidth)
- maxBottom = Math.max(maxBottom, child.position.y + childHeight)
- }
-
- const width = Math.max(
- CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
- maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING
- )
- const height = Math.max(
- CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
- maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING
- )
-
- return { width, height }
+interface BlockExecutionData {
+ input: unknown
+ output: unknown
+ status: string
+ durationMs: number
+ /** Child trace spans for nested workflow blocks */
+ children?: TraceSpan[]
}
-/**
- * Finds the leftmost block ID from a workflow state.
- * Returns the block with the smallest x position, excluding subflow containers (loop/parallel).
- */
-export function getLeftmostBlockId(workflowState: WorkflowState | null | undefined): string | null {
- if (!workflowState?.blocks) return null
-
- let leftmostId: string | null = null
- let minX = Number.POSITIVE_INFINITY
-
- for (const [blockId, block] of Object.entries(workflowState.blocks)) {
- if (!block || block.type === 'loop' || block.type === 'parallel') continue
- const x = block.position?.x ?? Number.POSITIVE_INFINITY
- if (x < minX) {
- minX = x
- leftmostId = blockId
- }
- }
-
- return leftmostId
-}
-
-/**
- * Recursively calculates the absolute position of a block,
- * accounting for parent container offsets.
- */
-function calculateAbsolutePosition(
- block: BlockState,
- blocks: Record
-): { x: number; y: number } {
- if (!block.data?.parentId) {
- return block.position
- }
-
- const parentBlock = blocks[block.data.parentId]
- if (!parentBlock) {
- logger.warn(`Parent block not found for child block: ${block.id}`)
- return block.position
- }
-
- const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
-
- return {
- x: parentAbsolutePosition.x + block.position.x,
- y: parentAbsolutePosition.y + block.position.y,
- }
-}
-
-/** Execution status for edges/nodes in the preview */
-export type ExecutionStatus = 'success' | 'error' | 'not-executed'
-
-interface WorkflowPreviewProps {
+/** Represents a level in the workflow navigation stack */
+interface WorkflowStackEntry {
workflowState: WorkflowState
- className?: string
- height?: string | number
- width?: string | number
- isPannable?: boolean
- defaultPosition?: { x: number; y: number }
- defaultZoom?: number
- fitPadding?: number
- onNodeClick?: (blockId: string, mousePosition: { x: number; y: number }) => void
- /** Callback when a node is right-clicked */
- onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
- /** Callback when the canvas (empty area) is clicked */
- onPaneClick?: () => void
- /** Cursor style to show when hovering the canvas */
- cursorStyle?: 'default' | 'pointer' | 'grab'
- /** Map of executed block IDs to their status for highlighting the execution path */
- executedBlocks?: Record
- /** Currently selected block ID for highlighting */
- selectedBlockId?: string | null
- /** Skips expensive computations for thumbnails/template previews */
- lightweight?: boolean
+ traceSpans: TraceSpan[]
+ blockExecutions: Record
}
/**
- * Preview node types using minimal components without hooks or store subscriptions.
- * This prevents interaction issues while allowing canvas panning and node clicking.
+ * Extracts child trace spans from a workflow block's execution data.
+ * Checks both the `children` property (where trace span processing moves them)
+ * and the legacy `output.childTraceSpans` for compatibility.
*/
-const previewNodeTypes: NodeTypes = {
- workflowBlock: WorkflowPreviewBlock,
-}
+function extractChildTraceSpans(blockExecution: BlockExecutionData | undefined): TraceSpan[] {
+ if (!blockExecution) return []
-// Define edge types
-const edgeTypes: EdgeTypes = {
- default: WorkflowEdge,
- workflowEdge: WorkflowEdge, // Keep for backward compatibility
-}
+ if (Array.isArray(blockExecution.children) && blockExecution.children.length > 0) {
+ return blockExecution.children
+ }
-interface FitViewOnChangeProps {
- nodeIds: string
- fitPadding: number
- containerRef: React.RefObject
-}
-
-/**
- * Helper component that calls fitView when the set of nodes changes or when the container resizes.
- * Only triggers on actual node additions/removals, not on selection changes.
- * Must be rendered inside ReactFlowProvider.
- */
-function FitViewOnChange({ nodeIds, fitPadding, containerRef }: FitViewOnChangeProps) {
- const { fitView } = useReactFlow()
- const lastNodeIdsRef = useRef(null)
-
- // Fit view when nodes change
- useEffect(() => {
- if (!nodeIds.length) return
- const shouldFit = lastNodeIdsRef.current !== nodeIds
- if (!shouldFit) return
- lastNodeIdsRef.current = nodeIds
-
- const timeoutId = setTimeout(() => {
- fitView({ padding: fitPadding, duration: 200 })
- }, 50)
- return () => clearTimeout(timeoutId)
- }, [nodeIds, fitPadding, fitView])
-
- // Fit view when container resizes (debounced to avoid excessive calls during drag)
- useEffect(() => {
- const container = containerRef.current
- if (!container) return
-
- let timeoutId: ReturnType | null = null
-
- const resizeObserver = new ResizeObserver(() => {
- if (timeoutId) clearTimeout(timeoutId)
- timeoutId = setTimeout(() => {
- fitView({ padding: fitPadding, duration: 150 })
- }, 100)
- })
-
- resizeObserver.observe(container)
- return () => {
- if (timeoutId) clearTimeout(timeoutId)
- resizeObserver.disconnect()
+ if (blockExecution.output && typeof blockExecution.output === 'object') {
+ const output = blockExecution.output as Record
+ if (Array.isArray(output.childTraceSpans)) {
+ return output.childTraceSpans as TraceSpan[]
}
- }, [containerRef, fitPadding, fitView])
+ }
- return null
+ return []
}
-export function WorkflowPreview({
- workflowState,
+/**
+ * Builds block execution data from trace spans
+ */
+export function buildBlockExecutions(spans: TraceSpan[]): Record {
+ const blockExecutionMap: Record = {}
+
+ const collectBlockSpans = (traceSpans: TraceSpan[]): TraceSpan[] => {
+ const blockSpans: TraceSpan[] = []
+ for (const span of traceSpans) {
+ if (span.blockId) {
+ blockSpans.push(span)
+ }
+ if (span.children && Array.isArray(span.children)) {
+ blockSpans.push(...collectBlockSpans(span.children))
+ }
+ }
+ return blockSpans
+ }
+
+ const allBlockSpans = collectBlockSpans(spans)
+
+ for (const span of allBlockSpans) {
+ if (span.blockId && !blockExecutionMap[span.blockId]) {
+ blockExecutionMap[span.blockId] = {
+ input: redactApiKeys(span.input || {}),
+ output: redactApiKeys(span.output || {}),
+ status: span.status || 'unknown',
+ durationMs: span.duration || 0,
+ children: span.children,
+ }
+ }
+ }
+
+ return blockExecutionMap
+}
+
+interface PreviewProps {
+ /** The workflow state to display */
+ workflowState: WorkflowState
+ /** Trace spans for the execution (optional - enables execution mode features) */
+ traceSpans?: TraceSpan[]
+ /** Pre-computed block executions (optional - will be built from traceSpans if not provided) */
+ blockExecutions?: Record
+ /** Additional CSS class names */
+ className?: string
+ /** Height of the component */
+ height?: string | number
+ /** Width of the component */
+ width?: string | number
+ /** Callback when canvas context menu is opened */
+ onCanvasContextMenu?: (e: React.MouseEvent) => void
+ /** Callback when a node context menu is opened */
+ onNodeContextMenu?: (blockId: string, mousePosition: { x: number; y: number }) => void
+ /** Whether to show border around the component */
+ showBorder?: boolean
+ /** Initial block to select (defaults to leftmost block) */
+ initialSelectedBlockId?: string | null
+ /** Whether to auto-select the leftmost block on mount */
+ autoSelectLeftmost?: boolean
+}
+
+/**
+ * Main preview component that combines PreviewCanvas with PreviewEditor
+ * and handles nested workflow navigation via a stack.
+ *
+ * @remarks
+ * - Manages navigation stack for drilling into nested workflow blocks
+ * - Displays back button when viewing nested workflows
+ * - Properly passes execution data through to nested levels
+ * - Can be used anywhere a workflow preview with editor is needed
+ */
+export function Preview({
+ workflowState: rootWorkflowState,
+ traceSpans: rootTraceSpans,
+ blockExecutions: providedBlockExecutions,
className,
height = '100%',
width = '100%',
- isPannable = true,
- defaultPosition,
- defaultZoom = 0.8,
- fitPadding = 0.25,
- onNodeClick,
+ onCanvasContextMenu,
onNodeContextMenu,
- onPaneClick,
- cursorStyle = 'grab',
- executedBlocks,
- selectedBlockId,
- lightweight = false,
-}: WorkflowPreviewProps) {
- const containerRef = useRef(null)
- const isValidWorkflowState = workflowState?.blocks && workflowState.edges
-
- const workflowStructureIds = useMemo(() => {
- if (!isValidWorkflowState) return ''
- const blockIds = Object.keys(workflowState.blocks || {})
- const edgeIds = (workflowState.edges || []).map((e) => e.id)
- return [...blockIds, ...edgeIds].join(',')
- }, [workflowState.blocks, workflowState.edges, isValidWorkflowState])
-
- // Pre-compute parent-child relationships for O(1) lookups in container dimension calculations
- const containerChildIndex = useMemo(() => {
- const index: Record = {}
- for (const block of Object.values(workflowState.blocks || {})) {
- if (block?.data?.parentId) {
- const parentId = block.data.parentId
- if (!index[parentId]) index[parentId] = []
- index[parentId].push(block)
- }
+ showBorder = false,
+ initialSelectedBlockId,
+ autoSelectLeftmost = true,
+}: PreviewProps) {
+ /** Initialize pinnedBlockId synchronously to ensure sidebar is present from first render */
+ const [pinnedBlockId, setPinnedBlockId] = useState(() => {
+ if (initialSelectedBlockId) return initialSelectedBlockId
+ if (autoSelectLeftmost) {
+ return getLeftmostBlockId(rootWorkflowState)
}
- return index
- }, [workflowState.blocks])
+ return null
+ })
- const nodes: Node[] = useMemo(() => {
- if (!isValidWorkflowState) return []
+ /** Stack for nested workflow navigation. Empty means we're at the root level. */
+ const [workflowStack, setWorkflowStack] = useState([])
- const nodeArray: Node[] = []
+ /** Block executions for the root level */
+ const rootBlockExecutions = useMemo(() => {
+ if (providedBlockExecutions) return providedBlockExecutions
+ if (!rootTraceSpans || !Array.isArray(rootTraceSpans)) return {}
+ return buildBlockExecutions(rootTraceSpans)
+ }, [providedBlockExecutions, rootTraceSpans])
- Object.entries(workflowState.blocks || {}).forEach(([blockId, block]) => {
- if (!block || !block.type) {
- logger.warn(`Skipping invalid block: ${blockId}`)
- return
- }
+ /** Current block executions - either from stack or root */
+ const blockExecutions = useMemo(() => {
+ if (workflowStack.length > 0) {
+ return workflowStack[workflowStack.length - 1].blockExecutions
+ }
+ return rootBlockExecutions
+ }, [workflowStack, rootBlockExecutions])
- const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
+ /** Current workflow state - either from stack or root */
+ const workflowState = useMemo(() => {
+ if (workflowStack.length > 0) {
+ return workflowStack[workflowStack.length - 1].workflowState
+ }
+ return rootWorkflowState
+ }, [workflowStack, rootWorkflowState])
- // Handle loop/parallel containers - use unified workflowBlock with isSubflow flag
- if (block.type === 'loop' || block.type === 'parallel') {
- const isSelected = selectedBlockId === blockId
- const childBlocks = containerChildIndex[blockId] || []
- const dimensions = calculateContainerDimensions(childBlocks)
- nodeArray.push({
- id: blockId,
- type: 'workflowBlock',
- position: absolutePosition,
- draggable: false,
- data: {
- type: block.type,
- name: block.name,
- isPreviewSelected: isSelected,
- isSubflow: true,
- subflowKind: block.type as 'loop' | 'parallel',
- width: dimensions.width,
- height: dimensions.height,
- },
- })
- return
- }
+ /** Whether we're in execution mode (have trace spans/block executions) */
+ const isExecutionMode = useMemo(() => {
+ return Object.keys(blockExecutions).length > 0
+ }, [blockExecutions])
- // Handle regular blocks
- const isSelected = selectedBlockId === blockId
+ /** Handler to drill down into a nested workflow block */
+ const handleDrillDown = useCallback(
+ (blockId: string, childWorkflowState: WorkflowState) => {
+ const blockExecution = blockExecutions[blockId]
+ const childTraceSpans = extractChildTraceSpans(blockExecution)
+ const childBlockExecutions = buildBlockExecutions(childTraceSpans)
- let executionStatus: ExecutionStatus | undefined
- if (executedBlocks) {
- const blockExecution = executedBlocks[blockId]
- if (blockExecution) {
- if (blockExecution.status === 'error') {
- executionStatus = 'error'
- } else if (blockExecution.status === 'success') {
- executionStatus = 'success'
- } else {
- executionStatus = 'not-executed'
- }
- } else {
- executionStatus = 'not-executed'
- }
- }
-
- nodeArray.push({
- id: blockId,
- type: 'workflowBlock',
- position: absolutePosition,
- draggable: false,
- // Blocks inside subflows need higher z-index to appear above the container
- zIndex: block.data?.parentId ? 10 : undefined,
- data: {
- type: block.type,
- name: block.name,
- isTrigger: block.triggerMode === true,
- horizontalHandles: block.horizontalHandles ?? false,
- enabled: block.enabled ?? true,
- isPreviewSelected: isSelected,
- executionStatus,
- subBlockValues: block.subBlocks,
- lightweight,
+ setWorkflowStack((prev) => [
+ ...prev,
+ {
+ workflowState: childWorkflowState,
+ traceSpans: childTraceSpans,
+ blockExecutions: childBlockExecutions,
},
- })
- })
+ ])
- return nodeArray
- }, [
- workflowStructureIds,
- workflowState.blocks,
- containerChildIndex,
- isValidWorkflowState,
- executedBlocks,
- selectedBlockId,
- lightweight,
- ])
+ /** Set pinned block synchronously to avoid double fitView from sidebar resize */
+ const leftmostId = getLeftmostBlockId(childWorkflowState)
+ setPinnedBlockId(leftmostId)
+ },
+ [blockExecutions]
+ )
- const edges: Edge[] = useMemo(() => {
- if (!isValidWorkflowState) return []
+ /** Handler to go back up the stack */
+ const handleGoBack = useCallback(() => {
+ setWorkflowStack((prev) => prev.slice(0, -1))
+ setPinnedBlockId(null)
+ }, [])
- return (workflowState.edges || []).map((edge) => {
- let executionStatus: ExecutionStatus | undefined
- if (executedBlocks) {
- const sourceExecuted = executedBlocks[edge.source]
- const targetExecuted = executedBlocks[edge.target]
+ /** Handlers for node interactions - memoized to prevent unnecessary re-renders */
+ const handleNodeClick = useCallback((blockId: string) => {
+ setPinnedBlockId(blockId)
+ }, [])
- if (sourceExecuted && targetExecuted) {
- // Edge is success if source succeeded and target was executed (even if target errored)
- if (sourceExecuted.status === 'success') {
- executionStatus = 'success'
- } else {
- executionStatus = 'not-executed'
- }
- } else {
- executionStatus = 'not-executed'
- }
- }
+ const handlePaneClick = useCallback(() => {
+ setPinnedBlockId(null)
+ }, [])
- return {
- id: edge.id,
- source: edge.source,
- target: edge.target,
- sourceHandle: edge.sourceHandle,
- targetHandle: edge.targetHandle,
- data: executionStatus ? { executionStatus } : undefined,
- // Raise executed edges above default edges
- zIndex: executionStatus === 'success' ? 10 : 0,
- }
- })
- }, [workflowStructureIds, workflowState.edges, isValidWorkflowState, executedBlocks])
+ const handleEditorClose = useCallback(() => {
+ setPinnedBlockId(null)
+ }, [])
- if (!isValidWorkflowState) {
- return (
-
-
-
⚠️ Logged State Not Found
-
- This log was migrated from the old system and doesn't contain workflow state data.
-
-
-
- )
- }
+ useEffect(() => {
+ setWorkflowStack([])
+ }, [rootWorkflowState])
+
+ const isNested = workflowStack.length > 0
return (
-
-
-
-
{
- logger.debug('Node clicked:', { nodeId: node.id, event })
- onNodeClick(node.id, { x: event.clientX, y: event.clientY })
- }
- : undefined
- }
- onNodeContextMenu={
- onNodeContextMenu
- ? (event, node) => {
- event.preventDefault()
- event.stopPropagation()
- onNodeContextMenu(node.id, { x: event.clientX, y: event.clientY })
- }
- : undefined
- }
- onPaneClick={onPaneClick}
- />
-
+
-
+
+ {pinnedBlockId && workflowState.blocks[pinnedBlockId] && (
+
+ )}
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
index f862a1290..d2a72a998 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
@@ -158,7 +158,7 @@ const allNavigationItems: NavigationItem[] = [
{ id: 'mcp', label: 'MCP Tools', icon: McpIcon, section: 'tools' },
{ id: 'environment', label: 'Environment', icon: FolderCode, section: 'system' },
{ id: 'apikeys', label: 'API Keys', icon: Key, section: 'system' },
- { id: 'workflow-mcp-servers', label: 'Deployed MCPs', icon: Server, section: 'system' },
+ { id: 'workflow-mcp-servers', label: 'MCP Servers', icon: Server, section: 'system' },
{
id: 'byok',
label: 'BYOK',
diff --git a/apps/sim/components/emcn/components/switch/switch.tsx b/apps/sim/components/emcn/components/switch/switch.tsx
index c9fc42261..118eb6f63 100644
--- a/apps/sim/components/emcn/components/switch/switch.tsx
+++ b/apps/sim/components/emcn/components/switch/switch.tsx
@@ -5,9 +5,8 @@ import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/core/utils/cn'
/**
- * Custom switch component with thin track design.
- * Track: 28px width, 6px height, 20px border-radius
- * Thumb: 14px diameter circle that overlaps the track
+ * Switch component styled to match Sim's design system.
+ * Uses brand color for checked state, neutral border for unchecked.
*/
const Switch = React.forwardRef<
React.ElementRef
,
@@ -16,21 +15,13 @@ const Switch = React.forwardRef<
-
+
))
diff --git a/bun.lock b/bun.lock
index f1df7669e..c76b5b453 100644
--- a/bun.lock
+++ b/bun.lock
@@ -291,7 +291,7 @@
},
"packages/ts-sdk": {
"name": "simstudio-ts-sdk",
- "version": "0.1.1",
+ "version": "0.1.2",
"dependencies": {
"node-fetch": "^3.3.2",
},