diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx
index b8550484950..38130ca0903 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx
@@ -1,10 +1,6 @@
import { memo, useCallback, useMemo } from 'react'
+import { BLOCK_DIMENSIONS, NoteBlockView } from '@sim/workflow-renderer'
import type { NodeProps } from 'reactflow'
-import remarkBreaks from 'remark-breaks'
-import { Streamdown } from 'streamdown'
-import 'streamdown/styles.css'
-import { cn, handleKeyboardActivation } from '@sim/emcn'
-import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
@@ -14,9 +10,7 @@ import type { WorkflowBlockProps } from '../workflow-block/types'
interface NoteBlockNodeData extends WorkflowBlockProps {}
-/**
- * Extract string value from subblock value object or primitive
- */
+/** Extracts the string content from a raw subblock value (string or `{ value }`). */
function extractFieldValue(rawValue: unknown): string | undefined {
if (typeof rawValue === 'string') return rawValue
if (rawValue && typeof rawValue === 'object' && 'value' in rawValue) {
@@ -26,441 +20,14 @@ function extractFieldValue(rawValue: unknown): string | undefined {
return undefined
}
-type EmbedInfo = {
- url: string
- type: 'iframe' | 'video' | 'audio'
- aspectRatio?: string
-}
-
-const EMBED_SCALE = 0.78
-const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
-
-function getTwitchParent(): string {
- return typeof window !== 'undefined' ? window.location.hostname : 'localhost'
-}
-
/**
- * Get embed info for supported media platforms
+ * Editor container for {@link NoteBlockView}.
+ *
+ * Resolves the note's markdown content from its subblock value, the enabled/ring
+ * visual state from {@link useBlockVisual}, and edit permission, then publishes
+ * deterministic dimensions and renders the pure view shared with the docs
+ * preview — injecting the editor-only {@link ActionBar} via the `actionBar` slot.
*/
-function getEmbedInfo(url: string): EmbedInfo | null {
- const youtubeMatch = url.match(
- /(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/
- )
- if (youtubeMatch) {
- return { url: `https://www.youtube.com/embed/${youtubeMatch[1]}`, type: 'iframe' }
- }
-
- const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
- if (vimeoMatch) {
- return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' }
- }
-
- const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/)
- if (dailymotionMatch) {
- return { url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`, type: 'iframe' }
- }
-
- const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/)
- if (twitchVideoMatch) {
- return {
- url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`,
- type: 'iframe',
- }
- }
-
- const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/)
- if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) {
- return {
- url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`,
- type: 'iframe',
- }
- }
-
- const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/)
- if (streamableMatch) {
- return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' }
- }
-
- const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/)
- if (wistiaMatch) {
- return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' }
- }
-
- const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/)
- if (tiktokMatch) {
- return {
- url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`,
- type: 'iframe',
- aspectRatio: '9/16',
- }
- }
-
- const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/)
- if (soundcloudMatch) {
- return {
- url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`,
- type: 'iframe',
- aspectRatio: '3/2',
- }
- }
-
- const spotifyTrackMatch = url.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/)
- if (spotifyTrackMatch) {
- return {
- url: `https://open.spotify.com/embed/track/${spotifyTrackMatch[1]}`,
- type: 'iframe',
- aspectRatio: '3.7/1',
- }
- }
-
- const spotifyAlbumMatch = url.match(/open\.spotify\.com\/album\/([a-zA-Z0-9]+)/)
- if (spotifyAlbumMatch) {
- return {
- url: `https://open.spotify.com/embed/album/${spotifyAlbumMatch[1]}`,
- type: 'iframe',
- aspectRatio: '2/3',
- }
- }
-
- const spotifyPlaylistMatch = url.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/)
- if (spotifyPlaylistMatch) {
- return {
- url: `https://open.spotify.com/embed/playlist/${spotifyPlaylistMatch[1]}`,
- type: 'iframe',
- aspectRatio: '2/3',
- }
- }
-
- const spotifyEpisodeMatch = url.match(/open\.spotify\.com\/episode\/([a-zA-Z0-9]+)/)
- if (spotifyEpisodeMatch) {
- return {
- url: `https://open.spotify.com/embed/episode/${spotifyEpisodeMatch[1]}`,
- type: 'iframe',
- aspectRatio: '2.5/1',
- }
- }
-
- const spotifyShowMatch = url.match(/open\.spotify\.com\/show\/([a-zA-Z0-9]+)/)
- if (spotifyShowMatch) {
- return {
- url: `https://open.spotify.com/embed/show/${spotifyShowMatch[1]}`,
- type: 'iframe',
- aspectRatio: '3.7/1',
- }
- }
-
- const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/)
- if (appleMusicSongMatch) {
- const [, country, songId] = appleMusicSongMatch
- return {
- url: `https://embed.music.apple.com/${country}/song/${songId}`,
- type: 'iframe',
- aspectRatio: '3/2',
- }
- }
-
- const appleMusicAlbumMatch = url.match(/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/)
- if (appleMusicAlbumMatch) {
- const [, country, albumId] = appleMusicAlbumMatch
- return {
- url: `https://embed.music.apple.com/${country}/album/${albumId}`,
- type: 'iframe',
- aspectRatio: '2/3',
- }
- }
-
- const appleMusicPlaylistMatch = url.match(
- /music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/
- )
- if (appleMusicPlaylistMatch) {
- const [, country, playlistId] = appleMusicPlaylistMatch
- return {
- url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`,
- type: 'iframe',
- aspectRatio: '2/3',
- }
- }
-
- const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/)
- if (loomMatch) {
- return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' }
- }
-
- const facebookVideoMatch =
- url.match(/facebook\.com\/.*\/videos\/(\d+)/) || url.match(/fb\.watch\/([a-zA-Z0-9_-]+)/)
- if (facebookVideoMatch) {
- return {
- url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`,
- type: 'iframe',
- }
- }
-
- const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/)
- if (instagramReelMatch) {
- return {
- url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`,
- type: 'iframe',
- aspectRatio: '9/16',
- }
- }
-
- const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/)
- if (instagramPostMatch) {
- return {
- url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`,
- type: 'iframe',
- aspectRatio: '4/5',
- }
- }
-
- const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/)
- if (twitterMatch) {
- return {
- url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`,
- type: 'iframe',
- aspectRatio: '3/4',
- }
- }
-
- const rumbleMatch =
- url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/)
- if (rumbleMatch) {
- return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' }
- }
-
- const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/)
- if (bilibiliMatch) {
- return {
- url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`,
- type: 'iframe',
- }
- }
-
- const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/)
- if (vidyardMatch) {
- return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' }
- }
-
- const cfStreamMatch =
- url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) ||
- url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/)
- if (cfStreamMatch) {
- return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' }
- }
-
- const twitchClipMatch =
- url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) ||
- url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/)
- if (twitchClipMatch) {
- return {
- url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`,
- type: 'iframe',
- }
- }
-
- const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/)
- if (mixcloudMatch) {
- return {
- url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`,
- type: 'iframe',
- aspectRatio: '2/1',
- }
- }
-
- const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/)
- if (googleDriveMatch) {
- return { url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, type: 'iframe' }
- }
-
- if (url.includes('dropbox.com') && /\.(mp4|mov|webm)/.test(url)) {
- const directUrl = url
- .replace('www.dropbox.com', 'dl.dropboxusercontent.com')
- .replace('?dl=0', '')
- return { url: directUrl, type: 'video' }
- }
-
- const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/)
- if (tenorMatch) {
- return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
- }
-
- const giphyMatch = url.match(/giphy\.com\/(?:gifs|embed)\/(?:.*-)?([a-zA-Z0-9]+)/)
- if (giphyMatch) {
- return { url: `https://giphy.com/embed/${giphyMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
- }
-
- if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) {
- return { url, type: 'video' }
- }
-
- if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) {
- return { url, type: 'audio' }
- }
-
- return null
-}
-
-/**
- * Compact markdown renderer for note blocks with tight spacing
- */
-const NOTE_REMARK_PLUGINS = [remarkBreaks]
-
-const NOTE_COMPONENTS = {
- p: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- h1: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- h2: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- h3: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- h4: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- ul: ({ children }: { children?: React.ReactNode }) => (
-
- ),
- ol: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- li: ({ children }: { children?: React.ReactNode }) => {children} ,
- inlineCode: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- code: ({ children, className, ...props }: { children?: React.ReactNode; className?: string }) => (
-
- {children}
-
- ),
- a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
- const embedInfo = href ? getEmbedInfo(href) : null
- if (embedInfo) {
- return (
-
-
- {children}
-
-
- {embedInfo.type === 'iframe' && (
-
-
-
- )}
- {embedInfo.type === 'video' && (
-
-
-
- )}
- {embedInfo.type === 'audio' && (
-
-
-
- )}
-
-
- )
- }
- return (
-
- {children}
-
- )
- },
- strong: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
- em: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
- blockquote: ({ children }: { children?: React.ReactNode }) => (
-
- {children}
-
- ),
- table: ({ children }: { children?: React.ReactNode }) => (
-
- ),
- thead: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
- tbody: ({ children }: { children?: React.ReactNode }) => {children} ,
- tr: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
- th: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
- td: ({ children }: { children?: React.ReactNode }) => (
- {children}
- ),
-}
-
-const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }) {
- return (
-
- {content}
-
- )
-})
-
export const NoteBlock = memo(function NoteBlock({
id,
data,
@@ -498,8 +65,8 @@ export const NoteBlock = memo(function NoteBlock({
const canEditWorkflow = userPermissions.canEdit && !data.isWorkflowLocked
/**
- * Calculate deterministic dimensions based on content structure.
- * Uses fixed width and computed height to avoid ResizeObserver jitter.
+ * Calculate deterministic dimensions based on content structure. Uses fixed
+ * width and computed height to avoid ResizeObserver jitter.
*/
useBlockDimensions({
blockId: id,
@@ -516,45 +83,14 @@ export const NoteBlock = memo(function NoteBlock({
})
return (
-
-
handleKeyboardActivation(event, handleClick)}
- >
-
-
-
-
-
-
- {isEmpty ? (
-
Add note…
- ) : (
-
- )}
-
-
- {hasRing && (
-
- )}
-
-
+ }
+ />
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/index.ts
index 426ef81dd9a..6001d27c0d2 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/index.ts
@@ -1,6 +1,3 @@
export { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop'
export { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel'
-export {
- SubflowNodeComponent,
- type SubflowNodeData,
-} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
+export { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
index 54b6a563c2a..efdb4153e8f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
@@ -1,9 +1,7 @@
import { memo, useMemo } from 'react'
-import { Badge, cn, handleKeyboardActivation } from '@sim/emcn'
-import { RepeatIcon, SplitIcon } from 'lucide-react'
-import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
-import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
-import { type DiffStatus, hasDiffStatus } from '@/lib/workflows/diff/types'
+import { type SubflowNodeData, SubflowNodeView } from '@sim/workflow-renderer'
+import { type NodeProps, useReactFlow } from 'reactflow'
+import { hasDiffStatus } from '@/lib/workflows/diff/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
@@ -11,53 +9,12 @@ import { useLastRunPath } from '@/stores/execution'
import { usePanelEditorStore } from '@/stores/panel'
/**
- * Data structure for subflow nodes (loop and parallel containers)
- */
-export interface SubflowNodeData {
- width?: number
- height?: number
- parentId?: string
- extent?: 'parent'
- isPreview?: boolean
- /** Whether this subflow is selected in preview mode */
- isPreviewSelected?: boolean
- kind: 'loop' | 'parallel'
- name?: string
- /** Execution status passed by preview/snapshot views */
- executionStatus?: 'success' | 'error' | 'not-executed'
- /** Whether the parent workflow is locked and should render as read-only */
- isWorkflowLocked?: boolean
-}
-
-const HANDLE_STYLE = {
- top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
- transform: 'translateY(-50%)',
-} as const
-
-/**
- * Reusable class names for Handle components.
- * Matches the styling pattern from workflow-block.tsx.
- */
-const getHandleClasses = (position: 'left' | 'right') => {
- const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
- const colorClasses = '!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',
- }
-
- return cn(baseClasses, colorClasses, positionClasses[position])
-}
-
-/**
- * Subflow node component for loop and parallel execution containers.
- * Renders a resizable container with a header displaying the block name and icon,
- * handles for connections, and supports nested execution contexts.
+ * Editor container for {@link SubflowNodeView}.
*
- * @param props - Node properties containing data and id
- * @returns Rendered subflow node component
+ * Resolves the subflow's enabled/locked/focus/diff/run state from the editor
+ * stores, computes its nesting depth from the ReactFlow node tree, and renders
+ * the pure view shared with the docs preview — injecting the editor-only
+ * {@link ActionBar} through the view's `actionBar` slot.
*/
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps) => {
const { getNodes } = useReactFlow()
@@ -66,7 +23,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
+ const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const isFocused = currentBlockId === id
- const isPreviewSelected = data?.isPreviewSelected || false
-
const lastRunPath = useLastRunPath()
const executionStatus = data.executionStatus
const runPathStatus: 'success' | 'error' | undefined =
@@ -91,8 +46,8 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps {
let level = 0
@@ -108,178 +63,21 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps {
- if (!hasRing) return undefined
- if (isFocused || isSelected || isPreviewSelected) return 'var(--brand-secondary)'
- if (diffStatus === 'new') return 'var(--brand-accent)'
- if (diffStatus === 'edited') return 'var(--warning)'
- if (runPathStatus === 'success') {
- return executionStatus ? 'var(--brand-accent)' : 'var(--border-success)'
- }
- if (runPathStatus === 'error') return 'var(--text-error)'
- return undefined
- }
- const ringColor = getRingColor()
-
return (
-
-
- {!isPreview &&
}
-
- {/* Header Section */}
-
setCurrentBlockId(id)}
- onKeyDown={(event) => handleKeyboardActivation(event, () => setCurrentBlockId(id))}
- className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-2 pr-3 pl-2 [&:active]:cursor-grabbing'
- style={{ pointerEvents: 'auto' }}
- >
-
-
-
-
-
- {blockName}
-
-
-
- {!isEnabled && disabled }
- {isLocked && locked }
-
-
-
- {/*
- * Subflow body background. Captures clicks to select the subflow in the
- * panel editor, matching the header click behavior. Child nodes and edges
- * are rendered as sibling divs at the viewport level by ReactFlow (not as
- * DOM children), so enabling pointer events here doesn't block them.
- */}
-
setCurrentBlockId(id)}
- onKeyDown={(event) => handleKeyboardActivation(event, () => setCurrentBlockId(id))}
- />
-
- {!isPreview && canEditWorkflow && (
-
- )}
-
-
- {/* Subflow Start */}
-
- Start
-
-
-
-
-
- {/* Input handle on left middle */}
-
-
- {/* Output handle on right middle */}
-
-
-
+
setCurrentBlockId(id)}
+ actionBar={ }
+ />
)
})
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 e7ab081e103..df248f3b392 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,6 +1,7 @@
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 { isEqual } from 'es-toolkit'
import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
@@ -8,7 +9,6 @@ import { useStoreWithEqualityFn } from 'zustand/traditional'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createMcpToolId } from '@/lib/mcp/shared'
import { getProviderIdFromServiceId } from '@/lib/oauth'
-import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
import {
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx
index ed4840eae81..eff2ef11c30 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx
@@ -1,8 +1,7 @@
import { memo, useMemo } from 'react'
-import { X } from 'lucide-react'
-import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
+import { type EdgeDiffStatus, WorkflowEdgeView } from '@sim/workflow-renderer'
+import type { EdgeProps } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
-import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
import { useLastRunEdges } from '@/stores/execution'
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
@@ -12,35 +11,14 @@ interface WorkflowEdgeProps extends EdgeProps {
targetHandle?: string | null
}
-const WorkflowEdgeComponent = ({
- id,
- sourceX,
- sourceY,
- targetX,
- targetY,
- sourcePosition,
- targetPosition,
- data,
- style,
- source,
- target,
- sourceHandle,
- targetHandle,
-}: WorkflowEdgeProps) => {
- const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left'
-
- const [edgePath, labelX, labelY] = getSmoothStepPath({
- sourceX,
- sourceY,
- sourcePosition,
- targetX,
- targetY,
- targetPosition,
- borderRadius: 8,
- offset: isHorizontal ? 30 : 20,
- })
-
- const isSelected = data?.isSelected ?? false
+/**
+ * Editor container for {@link WorkflowEdgeView}.
+ *
+ * Reads the diff and execution stores, resolves the edge's diff/run state, and
+ * passes it to the pure renderer shared with the docs preview.
+ */
+const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => {
+ const { id, data, source, target, sourceHandle, targetHandle } = props
const { diffAnalysis, isShowingDiff, isDiffReady } = useWorkflowDiffStore(
useShallow((state) => ({
@@ -51,14 +29,12 @@ const WorkflowEdgeComponent = ({
)
const lastRunEdges = useLastRunEdges()
- const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
- const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
const previewExecutionStatus = (
data as { executionStatus?: 'success' | 'error' | 'not-executed' } | undefined
)?.executionStatus
- const edgeRunStatus = previewExecutionStatus || lastRunEdges.get(id)
+ const runStatus = previewExecutionStatus || lastRunEdges.get(id)
- const edgeDiffStatus = useMemo((): EdgeDiffStatus => {
+ const diffStatus = useMemo((): EdgeDiffStatus => {
if (data?.isDeleted) return 'deleted'
if (!diffAnalysis?.edge_diff || !isDiffReady) return null
@@ -84,84 +60,14 @@ const WorkflowEdgeComponent = ({
targetHandle,
])
- const edgeStyle = useMemo(() => {
- let color = 'var(--workflow-edge)'
- let opacity = 1
-
- if (edgeDiffStatus === 'deleted') {
- color = 'var(--text-error)'
- opacity = 0.7
- } else if (edgeDiffStatus === 'new') {
- color = 'var(--brand-accent)'
- } else if (edgeRunStatus === 'success') {
- // Use green for preview mode, default for canvas execution
- color = previewExecutionStatus ? 'var(--brand-accent)' : 'var(--border-success)'
- } else if (edgeRunStatus === 'error') {
- color = 'var(--text-error)'
- } else if (isErrorEdge) {
- // Error edges that weren't taken stay red
- color = 'var(--text-error)'
- }
-
- if (isSelected) {
- opacity = 0.5
- }
-
- return {
- ...(style ?? {}),
- strokeWidth: edgeDiffStatus
- ? 3
- : edgeRunStatus === 'success' || edgeRunStatus === 'error'
- ? 2.5
- : isSelected
- ? 2.5
- : 2,
- stroke: color,
- strokeDasharray: edgeDiffStatus === 'deleted' ? '10,5' : undefined,
- opacity,
- }
- }, [style, edgeDiffStatus, isSelected, isErrorEdge, edgeRunStatus, previewExecutionStatus])
-
return (
- <>
-
-
- {isSelected && (
-
- {
- e.preventDefault()
- e.stopPropagation()
-
- if (data?.onDelete) {
- data.onDelete(id)
- }
- }}
- >
-
-
-
- )}
- >
+
)
}
-/**
- * Workflow edge component with execution status and diff visualization.
- *
- * @remarks
- * Edge coloring priority:
- * 1. Diff status (deleted/new) - for version comparison
- * 2. Execution status (success/error) - for run visualization
- * 3. Error edge default (red) - for untaken error paths
- * 4. Default edge color - normal workflow connections
- */
export const WorkflowEdge = memo(WorkflowEdgeComponent)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts
index f8578d95eab..77d5cde2635 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts
@@ -1,7 +1,7 @@
import { useCallback } from 'react'
import { createLogger } from '@sim/logger'
+import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import { useReactFlow } from 'reactflow'
-import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import {
calculateContainerDimensions,
clampPositionToContainer,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts
index 01068ff1115..ccefe9f3d3f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts
@@ -1,4 +1,4 @@
-import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
+import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import { getBlock } from '@/blocks/registry'
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts
index 18b3c4ec186..2fa772f78bb 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts
@@ -1,5 +1,5 @@
+import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import type { Edge, Node } from 'reactflow'
-import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils'
import type { BlockState } from '@/stores/workflows/workflow/types'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
index dcf180f6f9f..1e87115e592 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
@@ -16,12 +16,13 @@ import 'reactflow/dist/style.css'
import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
+import type { SubflowNodeData } from '@sim/workflow-renderer'
+import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import { useShallow } from 'zustand/react/shallow'
import { useSession } from '@/lib/auth/auth-client'
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/base-tool'
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
import type { OAuthProvider } from '@/lib/oauth'
-import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -36,7 +37,6 @@ import { CanvasMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/compone
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { WorkflowSearchReplace } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace'
-import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls'
import {
useAutoLayout,
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx
index 7130ec1b7b6..0fbe922788d 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx
@@ -1,8 +1,8 @@
'use client'
import { type CSSProperties, memo, useMemo } from 'react'
+import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
import { Handle, type NodeProps, Position } from 'reactflow'
-import { HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import {
getDisplayValue,
resolveDropdownLabel,
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
index 21ab8164aba..56b91370308 100644
--- 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
@@ -2,9 +2,9 @@
import { memo } from 'react'
import { Badge, cn } from '@sim/emcn'
+import { HANDLE_POSITIONS } from '@sim/workflow-renderer'
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'
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
index 96484163a96..ee0790ad673 100644
--- 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
@@ -15,7 +15,7 @@ import 'reactflow/dist/style.css'
import { cn } from '@sim/emcn'
import { createLogger } from '@sim/logger'
-import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
+import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
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'
diff --git a/apps/sim/hooks/use-canvas-viewport.ts b/apps/sim/hooks/use-canvas-viewport.ts
index 44d3739fd3a..1c4cf902e15 100644
--- a/apps/sim/hooks/use-canvas-viewport.ts
+++ b/apps/sim/hooks/use-canvas-viewport.ts
@@ -1,6 +1,6 @@
import { useCallback, useMemo } from 'react'
+import { BLOCK_DIMENSIONS } from '@sim/workflow-renderer'
import type { Node, ReactFlowInstance } from 'reactflow'
-import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
interface VisibleBounds {
width: number
diff --git a/apps/sim/lib/workflows/autolayout/constants.ts b/apps/sim/lib/workflows/autolayout/constants.ts
index 8e64b3cfca2..f4a9356d4cb 100644
--- a/apps/sim/lib/workflows/autolayout/constants.ts
+++ b/apps/sim/lib/workflows/autolayout/constants.ts
@@ -2,7 +2,7 @@
* Autolayout Constants
*
* Layout algorithm specific constants for spacing, padding, and overlap detection.
- * Block dimensions are in @/lib/workflows/blocks/block-dimensions
+ * Block dimensions are in @sim/workflow-renderer
*/
/**
diff --git a/apps/sim/lib/workflows/autolayout/containers.ts b/apps/sim/lib/workflows/autolayout/containers.ts
index f1f68ebf5b3..f3b7a244199 100644
--- a/apps/sim/lib/workflows/autolayout/containers.ts
+++ b/apps/sim/lib/workflows/autolayout/containers.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import {
CONTAINER_PADDING_X,
CONTAINER_PADDING_Y,
@@ -7,7 +8,6 @@ import {
import { layoutBlocksCore } from '@/lib/workflows/autolayout/core'
import type { Edge, LayoutOptions } from '@/lib/workflows/autolayout/types'
import { filterLayoutEligibleBlockIds, getBlocksByParent } from '@/lib/workflows/autolayout/utils'
-import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('AutoLayout:Containers')
diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts
index 014aa37ea3e..94035ebe90d 100644
--- a/apps/sim/lib/workflows/autolayout/core.ts
+++ b/apps/sim/lib/workflows/autolayout/core.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@sim/workflow-renderer'
import {
CONTAINER_LAYOUT_OPTIONS,
DEFAULT_LAYOUT_OPTIONS,
@@ -11,7 +12,6 @@ import {
prepareBlockMetrics,
snapNodesToGrid,
} from '@/lib/workflows/autolayout/utils'
-import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions'
import { EDGE } from '@/executor/constants'
import type { BlockState } from '@/stores/workflows/workflow/types'
diff --git a/apps/sim/lib/workflows/autolayout/targeted.ts b/apps/sim/lib/workflows/autolayout/targeted.ts
index cd8539e84be..0d8c9c2202f 100644
--- a/apps/sim/lib/workflows/autolayout/targeted.ts
+++ b/apps/sim/lib/workflows/autolayout/targeted.ts
@@ -1,3 +1,4 @@
+import { CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import {
CONTAINER_PADDING,
DEFAULT_HORIZONTAL_SPACING,
@@ -17,7 +18,6 @@ import {
shouldSkipAutoLayout,
snapPositionToGrid,
} from '@/lib/workflows/autolayout/utils'
-import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import type { BlockState } from '@/stores/workflows/workflow/types'
type TargetedBlockInfo = {
diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts
index bd77abeb980..cd8fc3ada11 100644
--- a/apps/sim/lib/workflows/autolayout/utils.ts
+++ b/apps/sim/lib/workflows/autolayout/utils.ts
@@ -1,3 +1,4 @@
+import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer'
import {
AUTO_LAYOUT_EXCLUDED_TYPES,
CONTAINER_BLOCK_TYPES,
@@ -9,7 +10,6 @@ import {
ROOT_PADDING_Y,
} from '@/lib/workflows/autolayout/constants'
import type { BlockMetrics, BoundingBox, Edge, GraphNode } from '@/lib/workflows/autolayout/types'
-import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions'
import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology'
import {
diff --git a/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts b/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts
index 24e236f7333..b3d8145e91b 100644
--- a/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts
+++ b/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts
@@ -1,4 +1,4 @@
-import { BLOCK_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
+import { BLOCK_DIMENSIONS } from '@sim/workflow-renderer'
import type { BlockConfig } from '@/blocks/types'
interface WorkflowBlockDimensionsInput {
diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts
index ee86254525e..40c28328ee1 100644
--- a/apps/sim/next.config.ts
+++ b/apps/sim/next.config.ts
@@ -172,6 +172,7 @@ const nextConfig: NextConfig = {
'@t3-oss/env-core',
'@sim/db',
'@sim/emcn',
+ '@sim/workflow-renderer',
],
async headers() {
return [
diff --git a/apps/sim/package.json b/apps/sim/package.json
index c94cc5aba6d..2809c27375c 100644
--- a/apps/sim/package.json
+++ b/apps/sim/package.json
@@ -105,6 +105,7 @@
"@sim/security": "workspace:*",
"@sim/utils": "workspace:*",
"@sim/workflow-persistence": "workspace:*",
+ "@sim/workflow-renderer": "workspace:*",
"@sim/workflow-types": "workspace:*",
"@t3-oss/env-nextjs": "0.13.4",
"@tanstack/react-query": "5.90.8",
diff --git a/apps/sim/tailwind.config.ts b/apps/sim/tailwind.config.ts
index cd978e7c068..0d65b3b258a 100644
--- a/apps/sim/tailwind.config.ts
+++ b/apps/sim/tailwind.config.ts
@@ -7,6 +7,7 @@ export default {
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'../../packages/emcn/src/**/*.{js,ts,jsx,tsx}',
+ '../../packages/workflow-renderer/src/**/*.{js,ts,jsx,tsx}',
'!./app/node_modules/**',
'!**/node_modules/**',
],
diff --git a/bun.lock b/bun.lock
index 2b92202f9ae..1d0973d9c00 100644
--- a/bun.lock
+++ b/bun.lock
@@ -171,6 +171,7 @@
"@sim/security": "workspace:*",
"@sim/utils": "workspace:*",
"@sim/workflow-persistence": "workspace:*",
+ "@sim/workflow-renderer": "workspace:*",
"@sim/workflow-types": "workspace:*",
"@t3-oss/env-nextjs": "0.13.4",
"@tanstack/react-query": "5.90.8",
@@ -527,6 +528,27 @@
"typescript": "^5.7.3",
},
},
+ "packages/workflow-renderer": {
+ "name": "@sim/workflow-renderer",
+ "version": "0.1.0",
+ "devDependencies": {
+ "@sim/tsconfig": "workspace:*",
+ "@types/react": "^19",
+ "lucide-react": "^0.479.0",
+ "react": "19.2.4",
+ "reactflow": "^11.11.4",
+ "remark-breaks": "^4.0.0",
+ "streamdown": "2.5.0",
+ "typescript": "^5.7.3",
+ },
+ "peerDependencies": {
+ "lucide-react": ">=0.479.0",
+ "react": "^19",
+ "reactflow": "^11.11.4",
+ "remark-breaks": "^4.0.0",
+ "streamdown": ">=2.5.0",
+ },
+ },
"packages/workflow-types": {
"name": "@sim/workflow-types",
"version": "0.1.0",
@@ -1544,6 +1566,8 @@
"@sim/workflow-persistence": ["@sim/workflow-persistence@workspace:packages/workflow-persistence"],
+ "@sim/workflow-renderer": ["@sim/workflow-renderer@workspace:packages/workflow-renderer"],
+
"@sim/workflow-types": ["@sim/workflow-types@workspace:packages/workflow-types"],
"@smithy/config-resolver": ["@smithy/config-resolver@4.6.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "tslib": "^2.6.2" } }, "sha512-NJF/Xc69G68BzZMKMEpWkCY9HjZJzTWztTW4VxBC2SodX+H60xw+NGckNhkgg4uMRHrpDkhWeBeigM3YJmv1FQ=="],
diff --git a/packages/workflow-renderer/package.json b/packages/workflow-renderer/package.json
new file mode 100644
index 00000000000..65274c95eb0
--- /dev/null
+++ b/packages/workflow-renderer/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@sim/workflow-renderer",
+ "version": "0.1.0",
+ "private": true,
+ "sideEffects": [
+ "**/*.css",
+ "**/note-block-view.tsx"
+ ],
+ "type": "module",
+ "license": "Apache-2.0",
+ "engines": {
+ "bun": ">=1.2.13",
+ "node": ">=20.0.0"
+ },
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "default": "./src/index.ts"
+ }
+ },
+ "scripts": {
+ "type-check": "tsc --noEmit",
+ "lint": "biome check --write --unsafe .",
+ "lint:check": "biome check .",
+ "format": "biome format --write .",
+ "format:check": "biome format ."
+ },
+ "peerDependencies": {
+ "lucide-react": ">=0.479.0",
+ "react": "^19",
+ "reactflow": "^11.11.4",
+ "remark-breaks": "^4.0.0",
+ "streamdown": ">=2.5.0"
+ },
+ "devDependencies": {
+ "@sim/tsconfig": "workspace:*",
+ "@types/react": "^19",
+ "lucide-react": "^0.479.0",
+ "react": "19.2.4",
+ "reactflow": "^11.11.4",
+ "remark-breaks": "^4.0.0",
+ "streamdown": "2.5.0",
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/packages/workflow-renderer/src/css-modules.d.ts b/packages/workflow-renderer/src/css-modules.d.ts
new file mode 100644
index 00000000000..21599b894ea
--- /dev/null
+++ b/packages/workflow-renderer/src/css-modules.d.ts
@@ -0,0 +1,9 @@
+/**
+ * Ambient declaration for CSS Modules. The renderer compiles `@sim/emcn` source
+ * (which imports CSS modules) as part of its program, so it needs this in scope
+ * for a standalone type-check. Consuming apps (Next.js) provide their own.
+ */
+declare module '*.module.css' {
+ const classes: { readonly [key: string]: string }
+ export default classes
+}
diff --git a/apps/sim/lib/workflows/blocks/block-dimensions.ts b/packages/workflow-renderer/src/dimensions.ts
similarity index 100%
rename from apps/sim/lib/workflows/blocks/block-dimensions.ts
rename to packages/workflow-renderer/src/dimensions.ts
diff --git a/packages/workflow-renderer/src/edge/workflow-edge-view.tsx b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx
new file mode 100644
index 00000000000..6256d8f70df
--- /dev/null
+++ b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx
@@ -0,0 +1,134 @@
+import { useMemo } from 'react'
+import { X } from 'lucide-react'
+import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
+import type { EdgeDiffStatus, EdgeRunStatus } from '../types'
+
+/**
+ * Props for the pure workflow edge renderer.
+ *
+ * Geometry and `data` come straight from ReactFlow. The visual state that would
+ * otherwise be read from stores — diff status, run status, and whether the run
+ * status originated from a preview — is resolved by the container and passed in.
+ */
+export interface WorkflowEdgeViewProps extends EdgeProps {
+ sourceHandle?: string | null
+ /** Pre-resolved diff state (container reads the diff store). */
+ diffStatus: EdgeDiffStatus
+ /** Pre-resolved execution outcome (container reads the execution store). */
+ runStatus: EdgeRunStatus
+ /** Whether `runStatus` came from a preview run (drives success coloring). */
+ isPreviewRun: boolean
+}
+
+/**
+ * Pure workflow edge renderer with execution status and diff visualization.
+ *
+ * @remarks
+ * Edge coloring priority:
+ * 1. Diff status (deleted/new) - for version comparison
+ * 2. Execution status (success/error) - for run visualization
+ * 3. Error edge default (red) - for untaken error paths
+ * 4. Default edge color - normal workflow connections
+ */
+export function WorkflowEdgeView({
+ id,
+ sourceX,
+ sourceY,
+ targetX,
+ targetY,
+ sourcePosition,
+ targetPosition,
+ data,
+ style,
+ sourceHandle,
+ diffStatus,
+ runStatus,
+ isPreviewRun,
+}: WorkflowEdgeViewProps) {
+ const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left'
+
+ const [edgePath, labelX, labelY] = getSmoothStepPath({
+ sourceX,
+ sourceY,
+ sourcePosition,
+ targetX,
+ targetY,
+ targetPosition,
+ borderRadius: 8,
+ offset: isHorizontal ? 30 : 20,
+ })
+
+ const isSelected = data?.isSelected ?? false
+
+ const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
+ const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
+
+ const edgeStyle = useMemo(() => {
+ let color = 'var(--workflow-edge)'
+ let opacity = 1
+
+ if (diffStatus === 'deleted') {
+ color = 'var(--text-error)'
+ opacity = 0.7
+ } else if (diffStatus === 'new') {
+ color = 'var(--brand-accent)'
+ } else if (runStatus === 'success') {
+ // Use green for preview mode, default for canvas execution
+ color = isPreviewRun ? 'var(--brand-accent)' : 'var(--border-success)'
+ } else if (runStatus === 'error') {
+ color = 'var(--text-error)'
+ } else if (isErrorEdge) {
+ // Error edges that weren't taken stay red
+ color = 'var(--text-error)'
+ }
+
+ if (isSelected) {
+ opacity = 0.5
+ }
+
+ return {
+ ...(style ?? {}),
+ strokeWidth: diffStatus
+ ? 3
+ : runStatus === 'success' || runStatus === 'error'
+ ? 2.5
+ : isSelected
+ ? 2.5
+ : 2,
+ stroke: color,
+ strokeDasharray: diffStatus === 'deleted' ? '10,5' : undefined,
+ opacity,
+ }
+ }, [style, diffStatus, isSelected, isErrorEdge, runStatus, isPreviewRun])
+
+ return (
+ <>
+
+
+ {isSelected && (
+
+ {
+ e.preventDefault()
+ e.stopPropagation()
+
+ if (data?.onDelete) {
+ data.onDelete(id)
+ }
+ }}
+ >
+
+
+
+ )}
+ >
+ )
+}
diff --git a/packages/workflow-renderer/src/index.ts b/packages/workflow-renderer/src/index.ts
new file mode 100644
index 00000000000..482d9a4551f
--- /dev/null
+++ b/packages/workflow-renderer/src/index.ts
@@ -0,0 +1,9 @@
+export * from './dimensions'
+export { WorkflowEdgeView, type WorkflowEdgeViewProps } from './edge/workflow-edge-view'
+export { NoteBlockView, type NoteBlockViewProps } from './note/note-block-view'
+export {
+ type SubflowNodeData,
+ SubflowNodeView,
+ type SubflowNodeViewProps,
+} from './subflow/subflow-node-view'
+export type { BlockRunStatus, DiffStatus, EdgeDiffStatus, EdgeRunStatus } from './types'
diff --git a/packages/workflow-renderer/src/note/note-block-view.tsx b/packages/workflow-renderer/src/note/note-block-view.tsx
new file mode 100644
index 00000000000..cf1dac38e8f
--- /dev/null
+++ b/packages/workflow-renderer/src/note/note-block-view.tsx
@@ -0,0 +1,517 @@
+import { memo, type ReactNode } from 'react'
+import remarkBreaks from 'remark-breaks'
+import { Streamdown } from 'streamdown'
+import 'streamdown/styles.css'
+import { cn, handleKeyboardActivation } from '@sim/emcn'
+
+type EmbedInfo = {
+ url: string
+ type: 'iframe' | 'video' | 'audio'
+ aspectRatio?: string
+}
+
+const EMBED_SCALE = 0.78
+const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
+
+function getTwitchParent(): string {
+ return typeof window !== 'undefined' ? window.location.hostname : 'localhost'
+}
+
+/**
+ * Get embed info for supported media platforms
+ */
+function getEmbedInfo(url: string): EmbedInfo | null {
+ const youtubeMatch = url.match(
+ /(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/
+ )
+ if (youtubeMatch) {
+ return { url: `https://www.youtube.com/embed/${youtubeMatch[1]}`, type: 'iframe' }
+ }
+
+ const vimeoMatch = url.match(/vimeo\.com\/(\d+)/)
+ if (vimeoMatch) {
+ return { url: `https://player.vimeo.com/video/${vimeoMatch[1]}`, type: 'iframe' }
+ }
+
+ const dailymotionMatch = url.match(/dailymotion\.com\/video\/([a-zA-Z0-9]+)/)
+ if (dailymotionMatch) {
+ return { url: `https://www.dailymotion.com/embed/video/${dailymotionMatch[1]}`, type: 'iframe' }
+ }
+
+ const twitchVideoMatch = url.match(/twitch\.tv\/videos\/(\d+)/)
+ if (twitchVideoMatch) {
+ return {
+ url: `https://player.twitch.tv/?video=${twitchVideoMatch[1]}&parent=${getTwitchParent()}`,
+ type: 'iframe',
+ }
+ }
+
+ const twitchChannelMatch = url.match(/twitch\.tv\/([a-zA-Z0-9_]+)(?:\/|$)/)
+ if (twitchChannelMatch && !url.includes('/videos/') && !url.includes('/clip/')) {
+ return {
+ url: `https://player.twitch.tv/?channel=${twitchChannelMatch[1]}&parent=${getTwitchParent()}`,
+ type: 'iframe',
+ }
+ }
+
+ const streamableMatch = url.match(/streamable\.com\/([a-zA-Z0-9]+)/)
+ if (streamableMatch) {
+ return { url: `https://streamable.com/e/${streamableMatch[1]}`, type: 'iframe' }
+ }
+
+ const wistiaMatch = url.match(/(?:wistia\.com|wistia\.net)\/(?:medias|embed)\/([a-zA-Z0-9]+)/)
+ if (wistiaMatch) {
+ return { url: `https://fast.wistia.net/embed/iframe/${wistiaMatch[1]}`, type: 'iframe' }
+ }
+
+ const tiktokMatch = url.match(/tiktok\.com\/@[^/]+\/video\/(\d+)/)
+ if (tiktokMatch) {
+ return {
+ url: `https://www.tiktok.com/embed/v2/${tiktokMatch[1]}`,
+ type: 'iframe',
+ aspectRatio: '9/16',
+ }
+ }
+
+ const soundcloudMatch = url.match(/soundcloud\.com\/([a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+)/)
+ if (soundcloudMatch) {
+ return {
+ url: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&show_teaser=false`,
+ type: 'iframe',
+ aspectRatio: '3/2',
+ }
+ }
+
+ const spotifyTrackMatch = url.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/)
+ if (spotifyTrackMatch) {
+ return {
+ url: `https://open.spotify.com/embed/track/${spotifyTrackMatch[1]}`,
+ type: 'iframe',
+ aspectRatio: '3.7/1',
+ }
+ }
+
+ const spotifyAlbumMatch = url.match(/open\.spotify\.com\/album\/([a-zA-Z0-9]+)/)
+ if (spotifyAlbumMatch) {
+ return {
+ url: `https://open.spotify.com/embed/album/${spotifyAlbumMatch[1]}`,
+ type: 'iframe',
+ aspectRatio: '2/3',
+ }
+ }
+
+ const spotifyPlaylistMatch = url.match(/open\.spotify\.com\/playlist\/([a-zA-Z0-9]+)/)
+ if (spotifyPlaylistMatch) {
+ return {
+ url: `https://open.spotify.com/embed/playlist/${spotifyPlaylistMatch[1]}`,
+ type: 'iframe',
+ aspectRatio: '2/3',
+ }
+ }
+
+ const spotifyEpisodeMatch = url.match(/open\.spotify\.com\/episode\/([a-zA-Z0-9]+)/)
+ if (spotifyEpisodeMatch) {
+ return {
+ url: `https://open.spotify.com/embed/episode/${spotifyEpisodeMatch[1]}`,
+ type: 'iframe',
+ aspectRatio: '2.5/1',
+ }
+ }
+
+ const spotifyShowMatch = url.match(/open\.spotify\.com\/show\/([a-zA-Z0-9]+)/)
+ if (spotifyShowMatch) {
+ return {
+ url: `https://open.spotify.com/embed/show/${spotifyShowMatch[1]}`,
+ type: 'iframe',
+ aspectRatio: '3.7/1',
+ }
+ }
+
+ const appleMusicSongMatch = url.match(/music\.apple\.com\/([a-z]{2})\/song\/[^/]+\/(\d+)/)
+ if (appleMusicSongMatch) {
+ const [, country, songId] = appleMusicSongMatch
+ return {
+ url: `https://embed.music.apple.com/${country}/song/${songId}`,
+ type: 'iframe',
+ aspectRatio: '3/2',
+ }
+ }
+
+ const appleMusicAlbumMatch = url.match(/music\.apple\.com\/([a-z]{2})\/album\/(?:[^/]+\/)?(\d+)/)
+ if (appleMusicAlbumMatch) {
+ const [, country, albumId] = appleMusicAlbumMatch
+ return {
+ url: `https://embed.music.apple.com/${country}/album/${albumId}`,
+ type: 'iframe',
+ aspectRatio: '2/3',
+ }
+ }
+
+ const appleMusicPlaylistMatch = url.match(
+ /music\.apple\.com\/([a-z]{2})\/playlist\/[^/]+\/(pl\.[a-zA-Z0-9]+)/
+ )
+ if (appleMusicPlaylistMatch) {
+ const [, country, playlistId] = appleMusicPlaylistMatch
+ return {
+ url: `https://embed.music.apple.com/${country}/playlist/${playlistId}`,
+ type: 'iframe',
+ aspectRatio: '2/3',
+ }
+ }
+
+ const loomMatch = url.match(/loom\.com\/share\/([a-zA-Z0-9]+)/)
+ if (loomMatch) {
+ return { url: `https://www.loom.com/embed/${loomMatch[1]}`, type: 'iframe' }
+ }
+
+ const facebookVideoMatch =
+ url.match(/facebook\.com\/.*\/videos\/(\d+)/) || url.match(/fb\.watch\/([a-zA-Z0-9_-]+)/)
+ if (facebookVideoMatch) {
+ return {
+ url: `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(url)}&show_text=false`,
+ type: 'iframe',
+ }
+ }
+
+ const instagramReelMatch = url.match(/instagram\.com\/reel\/([a-zA-Z0-9_-]+)/)
+ if (instagramReelMatch) {
+ return {
+ url: `https://www.instagram.com/reel/${instagramReelMatch[1]}/embed`,
+ type: 'iframe',
+ aspectRatio: '9/16',
+ }
+ }
+
+ const instagramPostMatch = url.match(/instagram\.com\/p\/([a-zA-Z0-9_-]+)/)
+ if (instagramPostMatch) {
+ return {
+ url: `https://www.instagram.com/p/${instagramPostMatch[1]}/embed`,
+ type: 'iframe',
+ aspectRatio: '4/5',
+ }
+ }
+
+ const twitterMatch = url.match(/(?:twitter\.com|x\.com)\/[^/]+\/status\/(\d+)/)
+ if (twitterMatch) {
+ return {
+ url: `https://platform.twitter.com/embed/Tweet.html?id=${twitterMatch[1]}`,
+ type: 'iframe',
+ aspectRatio: '3/4',
+ }
+ }
+
+ const rumbleMatch =
+ url.match(/rumble\.com\/embed\/([a-zA-Z0-9]+)/) || url.match(/rumble\.com\/([a-zA-Z0-9]+)-/)
+ if (rumbleMatch) {
+ return { url: `https://rumble.com/embed/${rumbleMatch[1]}/`, type: 'iframe' }
+ }
+
+ const bilibiliMatch = url.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/)
+ if (bilibiliMatch) {
+ return {
+ url: `https://player.bilibili.com/player.html?bvid=${bilibiliMatch[1]}&high_quality=1`,
+ type: 'iframe',
+ }
+ }
+
+ const vidyardMatch = url.match(/(?:vidyard\.com|share\.vidyard\.com)\/watch\/([a-zA-Z0-9]+)/)
+ if (vidyardMatch) {
+ return { url: `https://play.vidyard.com/${vidyardMatch[1]}`, type: 'iframe' }
+ }
+
+ const cfStreamMatch =
+ url.match(/cloudflarestream\.com\/([a-zA-Z0-9]+)/) ||
+ url.match(/videodelivery\.net\/([a-zA-Z0-9]+)/)
+ if (cfStreamMatch) {
+ return { url: `https://iframe.cloudflarestream.com/${cfStreamMatch[1]}`, type: 'iframe' }
+ }
+
+ const twitchClipMatch =
+ url.match(/clips\.twitch\.tv\/([a-zA-Z0-9_-]+)/) ||
+ url.match(/twitch\.tv\/[^/]+\/clip\/([a-zA-Z0-9_-]+)/)
+ if (twitchClipMatch) {
+ return {
+ url: `https://clips.twitch.tv/embed?clip=${twitchClipMatch[1]}&parent=${getTwitchParent()}`,
+ type: 'iframe',
+ }
+ }
+
+ const mixcloudMatch = url.match(/mixcloud\.com\/([^/]+\/[^/]+)/)
+ if (mixcloudMatch) {
+ return {
+ url: `https://www.mixcloud.com/widget/iframe/?feed=%2F${encodeURIComponent(mixcloudMatch[1])}%2F&hide_cover=1`,
+ type: 'iframe',
+ aspectRatio: '2/1',
+ }
+ }
+
+ const googleDriveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/)
+ if (googleDriveMatch) {
+ return { url: `https://drive.google.com/file/d/${googleDriveMatch[1]}/preview`, type: 'iframe' }
+ }
+
+ if (url.includes('dropbox.com') && /\.(mp4|mov|webm)/.test(url)) {
+ const directUrl = url
+ .replace('www.dropbox.com', 'dl.dropboxusercontent.com')
+ .replace('?dl=0', '')
+ return { url: directUrl, type: 'video' }
+ }
+
+ const tenorMatch = url.match(/tenor\.com\/view\/[^/]+-(\d+)/)
+ if (tenorMatch) {
+ return { url: `https://tenor.com/embed/${tenorMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
+ }
+
+ const giphyMatch = url.match(/giphy\.com\/(?:gifs|embed)\/(?:.*-)?([a-zA-Z0-9]+)/)
+ if (giphyMatch) {
+ return { url: `https://giphy.com/embed/${giphyMatch[1]}`, type: 'iframe', aspectRatio: '1/1' }
+ }
+
+ if (/\.(mp4|webm|ogg|mov)(\?|$)/i.test(url)) {
+ return { url, type: 'video' }
+ }
+
+ if (/\.(mp3|wav|m4a|aac)(\?|$)/i.test(url)) {
+ return { url, type: 'audio' }
+ }
+
+ return null
+}
+
+/**
+ * Compact markdown renderer for note blocks with tight spacing
+ */
+const NOTE_REMARK_PLUGINS = [remarkBreaks]
+
+const NOTE_COMPONENTS = {
+ p: ({ children }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ h1: ({ children }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ h2: ({ children }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ h3: ({ children }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ h4: ({ children }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ ul: ({ children }: { children?: ReactNode }) => (
+
+ ),
+ ol: ({ children }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ li: ({ children }: { children?: ReactNode }) => {children} ,
+ inlineCode: ({ children }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ code: ({ children, className, ...props }: { children?: ReactNode; className?: string }) => (
+
+ {children}
+
+ ),
+ a: ({ href, children }: { href?: string; children?: ReactNode }) => {
+ const embedInfo = href ? getEmbedInfo(href) : null
+ if (embedInfo) {
+ return (
+
+
+ {children}
+
+
+ {embedInfo.type === 'iframe' && (
+
+
+
+ )}
+ {embedInfo.type === 'video' && (
+
+
+
+ )}
+ {embedInfo.type === 'audio' && (
+
+
+
+ )}
+
+
+ )
+ }
+ return (
+
+ {children}
+
+ )
+ },
+ strong: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ em: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ blockquote: ({ children }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ table: ({ children }: { children?: ReactNode }) => (
+
+ ),
+ thead: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ tbody: ({ children }: { children?: ReactNode }) => {children} ,
+ tr: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ th: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ td: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+}
+
+const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }) {
+ return (
+
+ {content}
+
+ )
+})
+
+/**
+ * Props for the pure note renderer. The container resolves the markdown content
+ * (from the block's subblock value), enabled/ring visual state, and the select
+ * handler; the editor-only action bar is injected via the `actionBar` slot.
+ */
+export interface NoteBlockViewProps {
+ name?: string
+ /** Markdown content; an empty string renders the placeholder. */
+ content: string
+ isEnabled: boolean
+ hasRing: boolean
+ ringStyles: string
+ /** Selects this note in the editor panel. */
+ onSelect: () => void
+ /** Editor-only action bar; omit in read-only / preview contexts. */
+ actionBar?: ReactNode
+}
+
+/**
+ * Pure renderer for a note block: a draggable card with a title and a markdown
+ * body (rich text + embeds). Carries no store, socket, or permission coupling.
+ */
+export function NoteBlockView({
+ name,
+ content,
+ isEnabled,
+ hasRing,
+ ringStyles,
+ onSelect,
+ actionBar,
+}: NoteBlockViewProps) {
+ const isEmpty = content.trim().length === 0
+
+ return (
+
+
handleKeyboardActivation(event, onSelect)}
+ >
+ {actionBar}
+
+
+
+
+
+ {isEmpty ? (
+
Add note…
+ ) : (
+
+ )}
+
+
+ {hasRing && (
+
+ )}
+
+
+ )
+}
diff --git a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx
new file mode 100644
index 00000000000..4e179d0ceff
--- /dev/null
+++ b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx
@@ -0,0 +1,264 @@
+import type { ReactNode } from 'react'
+import { Badge, cn, handleKeyboardActivation } from '@sim/emcn'
+import { RepeatIcon, SplitIcon } from 'lucide-react'
+import { Handle, Position } from 'reactflow'
+import { HANDLE_POSITIONS } from '../dimensions'
+import type { BlockRunStatus, DiffStatus } from '../types'
+
+/** Data attached to loop/parallel container nodes. */
+export interface SubflowNodeData {
+ width?: number
+ height?: number
+ parentId?: string
+ extent?: 'parent'
+ isPreview?: boolean
+ /** Whether this subflow is selected in preview mode. */
+ isPreviewSelected?: boolean
+ kind: 'loop' | 'parallel'
+ name?: string
+ /** Execution status passed by preview/snapshot views. */
+ executionStatus?: 'success' | 'error' | 'not-executed'
+ /** Whether the parent workflow is locked and should render read-only. */
+ isWorkflowLocked?: boolean
+}
+
+/**
+ * Props for the pure subflow (loop/parallel container) renderer.
+ *
+ * Geometry and presentation come from `data`; the state that the editor would
+ * read from stores — enabled/locked flags, focus, execution and diff status,
+ * nesting depth, and edit permission — is resolved by the container and passed
+ * in. The editor-only action bar is injected via the `actionBar` slot so the
+ * pure renderer carries no store, socket, or permission coupling.
+ */
+export interface SubflowNodeViewProps {
+ id: string
+ data: SubflowNodeData
+ /** ReactFlow selection flag. */
+ selected?: boolean
+ isEnabled: boolean
+ isLocked: boolean
+ /** Whether this subflow is the focused block in the editor panel. */
+ isFocused: boolean
+ /** Resolved run-path outcome for the execution ring. */
+ runPathStatus?: BlockRunStatus
+ /** Diff state when comparing workflow versions. */
+ diffStatus?: DiffStatus
+ /** Depth in the parent container hierarchy (drives nesting styling). */
+ nestingLevel: number
+ canEditWorkflow: boolean
+ /** Selects this subflow in the editor panel. */
+ onSelect: () => void
+ /** Editor-only action bar; omit in read-only / preview contexts. */
+ actionBar?: ReactNode
+}
+
+const HANDLE_STYLE = {
+ top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
+ transform: 'translateY(-50%)',
+} as const
+
+/** Reusable handle classes, matching the workflow-block handle styling. */
+const getHandleClasses = (position: 'left' | 'right') => {
+ const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
+ const colorClasses = '!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',
+ }
+
+ return cn(baseClasses, colorClasses, positionClasses[position])
+}
+
+/**
+ * Pure renderer for loop/parallel execution containers: a resizable container
+ * with a header (icon, name, disabled/locked badges), a start pill with its
+ * source handle, and the left/right connection handles.
+ */
+export function SubflowNodeView({
+ id,
+ data,
+ selected,
+ isEnabled,
+ isLocked,
+ isFocused,
+ runPathStatus,
+ diffStatus,
+ nestingLevel,
+ canEditWorkflow,
+ onSelect,
+ actionBar,
+}: SubflowNodeViewProps) {
+ const isPreview = data?.isPreview || false
+ const isPreviewSelected = data?.isPreviewSelected || false
+ const executionStatus = data.executionStatus
+
+ const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
+ const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
+ const BlockIcon = data.kind === 'loop' ? RepeatIcon : SplitIcon
+ const blockIconBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
+ const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')
+
+ const isSelected = !isPreview && selected
+ const hasRing =
+ isFocused ||
+ isSelected ||
+ isPreviewSelected ||
+ diffStatus === 'new' ||
+ diffStatus === 'edited' ||
+ !!runPathStatus
+
+ /**
+ * Ring color priority: selection (blue) → diff (green/orange) → run-path
+ * (green/red). Uses boxShadow (not outline) so child nodes rendered as
+ * viewport-level siblings by ReactFlow don't clip the parent's ring.
+ */
+ const getRingColor = (): string | undefined => {
+ if (!hasRing) return undefined
+ if (isFocused || isSelected || isPreviewSelected) return 'var(--brand-secondary)'
+ if (diffStatus === 'new') return 'var(--brand-accent)'
+ if (diffStatus === 'edited') return 'var(--warning)'
+ if (runPathStatus === 'success') {
+ return executionStatus ? 'var(--brand-accent)' : 'var(--border-success)'
+ }
+ if (runPathStatus === 'error') return 'var(--text-error)'
+ return undefined
+ }
+ const ringColor = getRingColor()
+
+ return (
+
+
+ {!isPreview && actionBar}
+
+ {/* Header Section */}
+
handleKeyboardActivation(event, onSelect)}
+ className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-2 pr-3 pl-2 [&:active]:cursor-grabbing'
+ style={{ pointerEvents: 'auto' }}
+ >
+
+
+
+
+
+ {blockName}
+
+
+
+ {!isEnabled && disabled }
+ {isLocked && locked }
+
+
+
+ {/*
+ * Subflow body background. Captures clicks to select the subflow in the
+ * panel editor, matching the header click behavior. Child nodes and edges
+ * are rendered as sibling divs at the viewport level by ReactFlow (not as
+ * DOM children), so enabling pointer events here doesn't block them.
+ */}
+
handleKeyboardActivation(event, onSelect)}
+ />
+
+ {!isPreview && canEditWorkflow && (
+
+ )}
+
+
+ {/* Subflow Start */}
+
+ Start
+
+
+
+
+
+ {/* Input handle on left middle */}
+
+
+ {/* Output handle on right middle */}
+
+
+
+ )
+}
diff --git a/packages/workflow-renderer/src/types.ts b/packages/workflow-renderer/src/types.ts
new file mode 100644
index 00000000000..ec7615fab41
--- /dev/null
+++ b/packages/workflow-renderer/src/types.ts
@@ -0,0 +1,19 @@
+/**
+ * Shared rendering types for the pure workflow renderer.
+ *
+ * These describe the visual state a View component needs, resolved by the
+ * editor (or docs) container and passed in as props. They deliberately carry no
+ * store, socket, or query coupling.
+ */
+
+/** Diff state of an edge when comparing two workflow versions. */
+export type EdgeDiffStatus = 'new' | 'deleted' | 'unchanged' | null
+
+/** Execution outcome of an edge for run-path visualization. */
+export type EdgeRunStatus = 'success' | 'error' | 'not-executed' | undefined
+
+/** Diff state of a block when comparing two workflow versions. */
+export type DiffStatus = 'new' | 'edited' | undefined
+
+/** Execution outcome of a block on its run path. */
+export type BlockRunStatus = 'success' | 'error' | undefined
diff --git a/packages/workflow-renderer/tsconfig.json b/packages/workflow-renderer/tsconfig.json
new file mode 100644
index 00000000000..486f19cfc91
--- /dev/null
+++ b/packages/workflow-renderer/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@sim/tsconfig/library.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"]
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}