feat(workflow-renderer): extract edge, subflow, and note Views into @sim/workflow-renderer (#5263)

Adds a @sim/workflow-renderer package with pure, props-driven WorkflowEdgeView, SubflowNodeView, and NoteBlockView shared by the editor and (future) docs preview. Moves block-dimensions constants into the package. Each editor node becomes a thin Container that wires stores/permissions and injects the editor-only ActionBar via a slot. No optimizePackageImports for the workspace component packages (avoids the toast-style module duplication); Tailwind scans the package source.
This commit is contained in:
Waleed
2026-06-29 11:45:05 -07:00
committed by GitHub
parent 23ec96b02a
commit 27b2a4f585
32 changed files with 1113 additions and 843 deletions
@@ -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 }) => (
<p className='mb-1 break-words text-[var(--text-primary)] text-sm leading-[1.25rem] last:mb-0'>
{children}
</p>
),
h1: ({ children }: { children?: React.ReactNode }) => (
<h1 className='mt-3 mb-3 break-words font-semibold text-[var(--text-primary)] text-lg first:mt-0'>
{children}
</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
<h2 className='mt-2.5 mb-2.5 break-words font-semibold text-[var(--text-primary)] text-base first:mt-0'>
{children}
</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className='mt-2 mb-2 break-words font-semibold text-[var(--text-primary)] text-sm first:mt-0'>
{children}
</h3>
),
h4: ({ children }: { children?: React.ReactNode }) => (
<h4 className='mt-2 mb-2 break-words font-semibold text-[var(--text-primary)] text-xs first:mt-0'>
{children}
</h4>
),
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className='mt-1 mb-1 list-disc space-y-1 break-words pl-6 text-[var(--text-primary)] text-sm'>
{children}
</ul>
),
ol: ({ children }: { children?: React.ReactNode }) => (
<ol className='mt-1 mb-1 list-decimal space-y-1 break-words pl-6 text-[var(--text-primary)] text-sm'>
{children}
</ol>
),
li: ({ children }: { children?: React.ReactNode }) => <li className='break-words'>{children}</li>,
inlineCode: ({ children }: { children?: React.ReactNode }) => (
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1 py-0.5 font-mono text-[var(--caution)] text-xs'>
{children}
</code>
),
code: ({ children, className, ...props }: { children?: React.ReactNode; className?: string }) => (
<code
{...props}
className='block whitespace-pre-wrap break-words rounded bg-[var(--surface-5)] p-2 text-[var(--text-primary)] text-xs'
>
{children}
</code>
),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
const embedInfo = href ? getEmbedInfo(href) : null
if (embedInfo) {
return (
<span className='my-2 block w-full'>
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='mb-1 block break-all text-[var(--brand-secondary)] underline-offset-2 hover-hover:underline'
>
{children}
</a>
<span className='block w-full overflow-hidden rounded-md'>
{embedInfo.type === 'iframe' && (
<span
className='block overflow-hidden'
style={{
width: '100%',
aspectRatio: embedInfo.aspectRatio || '16/9',
}}
>
<iframe
src={embedInfo.url}
title='Media'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
loading='lazy'
className='origin-top-left'
style={{
width: EMBED_INVERSE_SCALE,
height: EMBED_INVERSE_SCALE,
transform: `scale(${EMBED_SCALE})`,
}}
/>
</span>
)}
{embedInfo.type === 'video' && (
<video
src={embedInfo.url}
controls
preload='metadata'
className='aspect-video w-full'
>
<track kind='captions' src='' default />
</video>
)}
{embedInfo.type === 'audio' && (
<audio src={embedInfo.url} controls preload='metadata' className='w-full'>
<track kind='captions' src='' default />
</audio>
)}
</span>
</span>
)
}
return (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover-hover:underline'
>
{children}
</a>
)
},
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
),
em: ({ children }: { children?: React.ReactNode }) => (
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
),
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className='my-4 break-words border-[var(--divider)] border-l-2 pl-4 text-[var(--text-primary)] italic [&>p:first-child]:mt-0 [&>p:last-child]:mb-0 [&>p]:my-2'>
{children}
</blockquote>
),
table: ({ children }: { children?: React.ReactNode }) => (
<div className='my-2 max-w-full overflow-x-auto'>
<table className='w-full border-collapse text-xs'>{children}</table>
</div>
),
thead: ({ children }: { children?: React.ReactNode }) => (
<thead className='border-[var(--border)] border-b'>{children}</thead>
),
tbody: ({ children }: { children?: React.ReactNode }) => <tbody>{children}</tbody>,
tr: ({ children }: { children?: React.ReactNode }) => (
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
),
th: ({ children }: { children?: React.ReactNode }) => (
<th className='px-2 py-1 text-left font-semibold text-[var(--text-primary)]'>{children}</th>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td className='px-2 py-1 text-[var(--text-secondary)]'>{children}</td>
),
}
const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }) {
return (
<Streamdown mode='static' remarkPlugins={NOTE_REMARK_PLUGINS} components={NOTE_COMPONENTS}>
{content}
</Streamdown>
)
})
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 (
<div className='group relative'>
<div
role='button'
tabIndex={0}
className={cn(
'note-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-lg border border-[var(--border)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
)}
onClick={handleClick}
onKeyDown={(event) => handleKeyboardActivation(event, handleClick)}
>
<ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} />
<div className='flex items-center justify-between border-[var(--divider)] border-b p-2'>
<div className='flex min-w-0 flex-1 items-center'>
<span
className={cn(
'truncate font-medium text-md',
!isEnabled && 'text-[var(--text-muted)]'
)}
title={name}
>
{name}
</span>
</div>
</div>
<div className='relative overflow-hidden p-2'>
<div className='relative max-w-full break-all'>
{isEmpty ? (
<p className='text-[var(--text-placeholder)] text-sm'>Add note…</p>
) : (
<NoteMarkdown content={content} />
)}
</div>
</div>
{hasRing && (
<div className={cn('pointer-events-none absolute inset-0 z-40 rounded-lg', ringStyles)} />
)}
</div>
</div>
<NoteBlockView
name={name}
content={content}
isEnabled={isEnabled}
hasRing={hasRing}
ringStyles={ringStyles}
onSelect={handleClick}
actionBar={<ActionBar blockId={id} blockType={type} disabled={!canEditWorkflow} />}
/>
)
})
@@ -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'
@@ -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<SubflowNodeData>) => {
const { getNodes } = useReactFlow()
@@ -66,7 +23,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
const currentWorkflow = useCurrentWorkflow()
const currentBlock = currentWorkflow.getBlockById(id)
const diffStatus: DiffStatus =
const diffStatus =
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
? currentBlock.is_diff
: undefined
@@ -75,12 +32,10 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
const isLocked = currentBlock?.locked ?? false
const isPreview = data?.isPreview || false
const setCurrentBlockId = usePanelEditorStore((state) => 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<Subf
: lastRunPath.get(id)
/**
* Calculate the nesting level of this subflow node based on its parent hierarchy.
* Used to apply appropriate styling for nested containers.
* Nesting depth, walking the parent chain so the view can apply nested
* container styling.
*/
const nestingLevel = useMemo(() => {
let level = 0
@@ -108,178 +63,21 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
return level
}, [data?.parentId, getNodes])
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')
/**
* Determine the ring styling based on subflow state priority:
* 1. Focused (selected in editor), selected (shift-click/box), or preview selected - blue ring
* 2. Diff status (version comparison) - green/orange ring
* 3. Run path status (execution result) - green/red ring
*/
const isSelected = !isPreview && selected
const hasRing =
isFocused ||
isSelected ||
isPreviewSelected ||
diffStatus === 'new' ||
diffStatus === 'edited' ||
!!runPathStatus
/**
* Compute the ring color for the subflow selection indicator.
* Uses boxShadow (not CSS outline) to match the ring styling of regular workflow blocks.
* This works because ReactFlow renders child nodes as sibling divs at the viewport level
* (not as DOM children), so children at zIndex 1000 don't clip the parent's boxShadow.
*/
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 (
<div className='group pointer-events-none relative'>
<div
className='relative select-none rounded-lg border border-[var(--border-1)] transition-block-bg'
style={{
width: data.width || 500,
height: data.height || 300,
overflow: 'visible',
pointerEvents: 'none',
...(ringColor && {
boxShadow: `0 0 0 1.75px ${ringColor}`,
}),
}}
data-node-id={id}
data-type='subflowNode'
data-nesting-level={nestingLevel}
data-subflow-selected={isFocused || isSelected || isPreviewSelected}
>
{!isPreview && <ActionBar blockId={id} blockType={data.kind} disabled={!canEditWorkflow} />}
{/* Header Section */}
<div
role='button'
tabIndex={0}
aria-label={`Select ${blockName}`}
onClick={() => 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' }}
>
<div className='flex min-w-0 flex-1 items-center gap-2.5'>
<div
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-md'
style={{ backgroundColor: isEnabled ? blockIconBg : 'gray' }}
>
<BlockIcon className='size-[16px] text-white' />
</div>
<span
className={cn(
'truncate font-medium text-md',
!isEnabled && 'text-[var(--text-muted)]'
)}
title={blockName}
>
{blockName}
</span>
</div>
<div className='flex items-center gap-1'>
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
</div>
</div>
{/*
* 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.
*/}
<div
role='button'
tabIndex={isPreview ? -1 : 0}
aria-label={`Select ${blockName}`}
className='workflow-drag-handle absolute inset-0 top-[44px] cursor-grab rounded-b-[8px] [&:active]:cursor-grabbing'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
onClick={() => setCurrentBlockId(id)}
onKeyDown={(event) => handleKeyboardActivation(event, () => setCurrentBlockId(id))}
/>
{!isPreview && canEditWorkflow && (
<div
role='separator'
aria-orientation='horizontal'
className='absolute right-[8px] bottom-2 z-20 flex size-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
)}
<div
className='relative h-[calc(100%-50px)] pt-4 pr-[80px] pb-4 pl-4'
data-dragarea='true'
style={{ pointerEvents: 'none' }}
>
{/* Subflow Start */}
<div
className='absolute top-4 left-[16px] flex items-center justify-center rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] px-3 py-1.5'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
data-parent-id={id}
data-node-role={`${data.kind}-start`}
data-extent='parent'
>
<span className='font-medium text-[var(--text-primary)] text-sm'>Start</span>
<Handle
type='source'
position={Position.Right}
id={startHandleId}
className={getHandleClasses('right')}
style={{
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
data-parent-id={id}
/>
</div>
</div>
{/* Input handle on left middle */}
<Handle
type='target'
position={Position.Left}
className={getHandleClasses('left')}
style={{
...HANDLE_STYLE,
pointerEvents: 'auto',
}}
/>
{/* Output handle on right middle */}
<Handle
type='source'
position={Position.Right}
className={getHandleClasses('right')}
style={{
...HANDLE_STYLE,
pointerEvents: 'auto',
}}
id={endHandleId}
/>
</div>
</div>
<SubflowNodeView
id={id}
data={data}
selected={selected}
isEnabled={isEnabled}
isLocked={isLocked}
isFocused={isFocused}
runPathStatus={runPathStatus}
diffStatus={diffStatus}
nestingLevel={nestingLevel}
canEditWorkflow={canEditWorkflow}
onSelect={() => setCurrentBlockId(id)}
actionBar={<ActionBar blockId={id} blockType={data.kind} disabled={!canEditWorkflow} />}
/>
)
})
@@ -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 {
@@ -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 (
<>
<BaseEdge path={edgePath} style={edgeStyle} interactionWidth={30} />
{isSelected && (
<EdgeLabelRenderer>
<button
type='button'
className='nodrag nopan group flex size-[22px] cursor-pointer items-center justify-center transition-colors'
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
zIndex: 1011,
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (data?.onDelete) {
data.onDelete(id)
}
}}
>
<X className='size-4 text-[var(--text-error)] transition-colors group-hover:text-[color-mix(in_srgb,var(--text-error)_80%,transparent)]' />
</button>
</EdgeLabelRenderer>
)}
</>
<WorkflowEdgeView
{...props}
diffStatus={diffStatus}
runStatus={runStatus}
isPreviewRun={Boolean(previewExecutionStatus)}
/>
)
}
/**
* 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)
@@ -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,
@@ -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'
/**
@@ -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'
@@ -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,
@@ -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,
@@ -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'
@@ -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'
+1 -1
View File
@@ -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
@@ -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
*/
/**
@@ -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')
+1 -1
View File
@@ -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'
@@ -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 = {
+1 -1
View File
@@ -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 {
@@ -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 {
+1
View File
@@ -172,6 +172,7 @@ const nextConfig: NextConfig = {
'@t3-oss/env-core',
'@sim/db',
'@sim/emcn',
'@sim/workflow-renderer',
],
async headers() {
return [
+1
View File
@@ -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",
+1
View File
@@ -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/**',
],
+24
View File
@@ -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=="],
+45
View File
@@ -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"
}
}
+9
View File
@@ -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
}
@@ -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 (
<>
<BaseEdge path={edgePath} style={edgeStyle} interactionWidth={30} />
{isSelected && (
<EdgeLabelRenderer>
<button
type='button'
className='nodrag nopan group flex size-[22px] cursor-pointer items-center justify-center transition-colors'
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
zIndex: 1011,
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (data?.onDelete) {
data.onDelete(id)
}
}}
>
<X className='size-4 text-[var(--text-error)] transition-colors group-hover:text-[color-mix(in_srgb,var(--text-error)_80%,transparent)]' />
</button>
</EdgeLabelRenderer>
)}
</>
)
}
+9
View File
@@ -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'
@@ -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 }) => (
<p className='mb-1 break-words text-[var(--text-primary)] text-sm leading-[1.25rem] last:mb-0'>
{children}
</p>
),
h1: ({ children }: { children?: ReactNode }) => (
<h1 className='mt-3 mb-3 break-words font-semibold text-[var(--text-primary)] text-lg first:mt-0'>
{children}
</h1>
),
h2: ({ children }: { children?: ReactNode }) => (
<h2 className='mt-2.5 mb-2.5 break-words font-semibold text-[var(--text-primary)] text-base first:mt-0'>
{children}
</h2>
),
h3: ({ children }: { children?: ReactNode }) => (
<h3 className='mt-2 mb-2 break-words font-semibold text-[var(--text-primary)] text-sm first:mt-0'>
{children}
</h3>
),
h4: ({ children }: { children?: ReactNode }) => (
<h4 className='mt-2 mb-2 break-words font-semibold text-[var(--text-primary)] text-xs first:mt-0'>
{children}
</h4>
),
ul: ({ children }: { children?: ReactNode }) => (
<ul className='mt-1 mb-1 list-disc space-y-1 break-words pl-6 text-[var(--text-primary)] text-sm'>
{children}
</ul>
),
ol: ({ children }: { children?: ReactNode }) => (
<ol className='mt-1 mb-1 list-decimal space-y-1 break-words pl-6 text-[var(--text-primary)] text-sm'>
{children}
</ol>
),
li: ({ children }: { children?: ReactNode }) => <li className='break-words'>{children}</li>,
inlineCode: ({ children }: { children?: ReactNode }) => (
<code className='whitespace-normal rounded bg-[var(--surface-5)] px-1 py-0.5 font-mono text-[var(--caution)] text-xs'>
{children}
</code>
),
code: ({ children, className, ...props }: { children?: ReactNode; className?: string }) => (
<code
{...props}
className='block whitespace-pre-wrap break-words rounded bg-[var(--surface-5)] p-2 text-[var(--text-primary)] text-xs'
>
{children}
</code>
),
a: ({ href, children }: { href?: string; children?: ReactNode }) => {
const embedInfo = href ? getEmbedInfo(href) : null
if (embedInfo) {
return (
<span className='my-2 block w-full'>
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='mb-1 block break-all text-[var(--brand-secondary)] underline-offset-2 hover-hover:underline'
>
{children}
</a>
<span className='block w-full overflow-hidden rounded-md'>
{embedInfo.type === 'iframe' && (
<span
className='block overflow-hidden'
style={{
width: '100%',
aspectRatio: embedInfo.aspectRatio || '16/9',
}}
>
<iframe
src={embedInfo.url}
title='Media'
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
allowFullScreen
loading='lazy'
className='origin-top-left'
style={{
width: EMBED_INVERSE_SCALE,
height: EMBED_INVERSE_SCALE,
transform: `scale(${EMBED_SCALE})`,
}}
/>
</span>
)}
{embedInfo.type === 'video' && (
<video
src={embedInfo.url}
controls
preload='metadata'
className='aspect-video w-full'
>
<track kind='captions' src='' default />
</video>
)}
{embedInfo.type === 'audio' && (
<audio src={embedInfo.url} controls preload='metadata' className='w-full'>
<track kind='captions' src='' default />
</audio>
)}
</span>
</span>
)
}
return (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='break-all text-[var(--brand-secondary)] underline-offset-2 hover-hover:underline'
>
{children}
</a>
)
},
strong: ({ children }: { children?: ReactNode }) => (
<strong className='break-words font-semibold text-[var(--text-primary)]'>{children}</strong>
),
em: ({ children }: { children?: ReactNode }) => (
<em className='break-words text-[var(--text-tertiary)]'>{children}</em>
),
blockquote: ({ children }: { children?: ReactNode }) => (
<blockquote className='my-4 break-words border-[var(--divider)] border-l-2 pl-4 text-[var(--text-primary)] italic [&>p:first-child]:mt-0 [&>p:last-child]:mb-0 [&>p]:my-2'>
{children}
</blockquote>
),
table: ({ children }: { children?: ReactNode }) => (
<div className='my-2 max-w-full overflow-x-auto'>
<table className='w-full border-collapse text-xs'>{children}</table>
</div>
),
thead: ({ children }: { children?: ReactNode }) => (
<thead className='border-[var(--border)] border-b'>{children}</thead>
),
tbody: ({ children }: { children?: ReactNode }) => <tbody>{children}</tbody>,
tr: ({ children }: { children?: ReactNode }) => (
<tr className='border-[var(--border)] border-b last:border-b-0'>{children}</tr>
),
th: ({ children }: { children?: ReactNode }) => (
<th className='px-2 py-1 text-left font-semibold text-[var(--text-primary)]'>{children}</th>
),
td: ({ children }: { children?: ReactNode }) => (
<td className='px-2 py-1 text-[var(--text-secondary)]'>{children}</td>
),
}
const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }) {
return (
<Streamdown mode='static' remarkPlugins={NOTE_REMARK_PLUGINS} components={NOTE_COMPONENTS}>
{content}
</Streamdown>
)
})
/**
* 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 (
<div className='group relative'>
<div
role='button'
tabIndex={0}
className={cn(
'note-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-lg border border-[var(--border)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
)}
onClick={onSelect}
onKeyDown={(event) => handleKeyboardActivation(event, onSelect)}
>
{actionBar}
<div className='flex items-center justify-between border-[var(--divider)] border-b p-2'>
<div className='flex min-w-0 flex-1 items-center'>
<span
className={cn(
'truncate font-medium text-md',
!isEnabled && 'text-[var(--text-muted)]'
)}
title={name}
>
{name}
</span>
</div>
</div>
<div className='relative overflow-hidden p-2'>
<div className='relative max-w-full break-all'>
{isEmpty ? (
<p className='text-[var(--text-placeholder)] text-sm'>Add note…</p>
) : (
<NoteMarkdown content={content} />
)}
</div>
</div>
{hasRing && (
<div className={cn('pointer-events-none absolute inset-0 z-40 rounded-lg', ringStyles)} />
)}
</div>
</div>
)
}
@@ -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 (
<div className='group pointer-events-none relative'>
<div
className='relative select-none rounded-lg border border-[var(--border-1)] transition-block-bg'
style={{
width: data.width || 500,
height: data.height || 300,
overflow: 'visible',
pointerEvents: 'none',
...(ringColor && {
boxShadow: `0 0 0 1.75px ${ringColor}`,
}),
}}
data-node-id={id}
data-type='subflowNode'
data-nesting-level={nestingLevel}
data-subflow-selected={isFocused || isSelected || isPreviewSelected}
>
{!isPreview && actionBar}
{/* Header Section */}
<div
role='button'
tabIndex={0}
aria-label={`Select ${blockName}`}
onClick={onSelect}
onKeyDown={(event) => 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' }}
>
<div className='flex min-w-0 flex-1 items-center gap-2.5'>
<div
className='flex size-[24px] flex-shrink-0 items-center justify-center rounded-md'
style={{ backgroundColor: isEnabled ? blockIconBg : 'gray' }}
>
<BlockIcon className='size-[16px] text-white' />
</div>
<span
className={cn(
'truncate font-medium text-md',
!isEnabled && 'text-[var(--text-muted)]'
)}
title={blockName}
>
{blockName}
</span>
</div>
<div className='flex items-center gap-1'>
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
</div>
</div>
{/*
* 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.
*/}
<div
role='button'
tabIndex={isPreview ? -1 : 0}
aria-label={`Select ${blockName}`}
className='workflow-drag-handle absolute inset-0 top-[44px] cursor-grab rounded-b-[8px] [&:active]:cursor-grabbing'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
onClick={onSelect}
onKeyDown={(event) => handleKeyboardActivation(event, onSelect)}
/>
{!isPreview && canEditWorkflow && (
<div
role='separator'
aria-orientation='horizontal'
className='absolute right-[8px] bottom-2 z-20 flex size-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
)}
<div
className='relative h-[calc(100%-50px)] pt-4 pr-[80px] pb-4 pl-4'
data-dragarea='true'
style={{ pointerEvents: 'none' }}
>
{/* Subflow Start */}
<div
className='absolute top-4 left-[16px] flex items-center justify-center rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] px-3 py-1.5'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
data-parent-id={id}
data-node-role={`${data.kind}-start`}
data-extent='parent'
>
<span className='font-medium text-[var(--text-primary)] text-sm'>Start</span>
<Handle
type='source'
position={Position.Right}
id={startHandleId}
className={getHandleClasses('right')}
style={{
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'auto',
}}
data-parent-id={id}
/>
</div>
</div>
{/* Input handle on left middle */}
<Handle
type='target'
position={Position.Left}
className={getHandleClasses('left')}
style={{
...HANDLE_STYLE,
pointerEvents: 'auto',
}}
/>
{/* Output handle on right middle */}
<Handle
type='source'
position={Position.Right}
className={getHandleClasses('right')}
style={{
...HANDLE_STYLE,
pointerEvents: 'auto',
}}
id={endHandleId}
/>
</div>
</div>
)
}
+19
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@sim/tsconfig/library.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}