chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
{
"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": {
"@sim/emcn": "workspace:*",
"@sim/utils": "workspace:*",
"lucide-react": ">=0.479.0",
"react": "^19",
"reactflow": "^11.11.4",
"remark-breaks": "^4.0.0",
"streamdown": ">=2.5.0"
},
"devDependencies": {
"@sim/emcn": "workspace:*",
"@sim/tsconfig": "workspace:*",
"@sim/utils": "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": "^7.0.2"
}
}
+11
View File
@@ -0,0 +1,11 @@
/**
* 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
}
declare module '*.css'
@@ -0,0 +1,47 @@
/**
* Shared Block Dimension Constants
*
* Single source of truth for block dimensions used by:
* - UI components (workflow-block, note-block, subflow-node)
* - Autolayout system
* - Node utilities
*/
export const BLOCK_DIMENSIONS = {
FIXED_WIDTH: 250,
HEADER_HEIGHT: 40,
MIN_HEIGHT: 100,
WORKFLOW_CONTENT_PADDING: 16,
WORKFLOW_ROW_HEIGHT: 29,
NOTE_CONTENT_PADDING: 14,
NOTE_MIN_CONTENT_HEIGHT: 20,
NOTE_BASE_CONTENT_HEIGHT: 60,
} as const
export const CONTAINER_DIMENSIONS = {
DEFAULT_WIDTH: 500,
DEFAULT_HEIGHT: 300,
MIN_WIDTH: 400,
MIN_HEIGHT: 200,
HEADER_HEIGHT: 50,
LEFT_PADDING: 16,
RIGHT_PADDING: 80,
TOP_PADDING: 16,
BOTTOM_PADDING: 16,
} as const
/**
* Handle position constants - must match CSS in workflow-block.tsx and subflow-node.tsx
*/
export const HANDLE_POSITIONS = {
/** Default Y offset from block top for source/target handles */
DEFAULT_Y_OFFSET: 20,
/** Error handle offset from block bottom */
ERROR_BOTTOM_OFFSET: 17,
/** Condition handle starting Y offset */
CONDITION_START_Y: 60,
/** Height per condition row */
CONDITION_ROW_HEIGHT: 29,
/** Subflow start handle Y offset (header 50px + pill offset 16px + pill center 14px) */
SUBFLOW_START_Y_OFFSET: 80,
} as const
@@ -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>
)}
</>
)
}
+14
View File
@@ -0,0 +1,14 @@
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'
export { SubBlockRowView, type SubBlockRowViewProps } from './workflow-block/sub-block-row-view'
export {
WorkflowBlockView,
type WorkflowBlockViewProps,
} from './workflow-block/workflow-block-view'
@@ -0,0 +1,25 @@
import { FloatingTooltip, isTextClipped, useFloatingTooltip } from '@sim/emcn'
interface OverflowSpanProps {
value: string
className: string
}
/**
* Truncated span that reveals its full value in a floating tooltip when — and
* only when — the text is actually clipped. Never use a native `title`
* attribute here: on the canvas it pops the browser's raw, unstyled tooltip
* with the full untruncated value (including raw code/JSON) over the graph.
*/
export function OverflowSpan({ value, className }: OverflowSpanProps) {
const { state, handlers } = useFloatingTooltip(isTextClipped)
return (
<>
<span className={className} {...handlers}>
{value}
</span>
<FloatingTooltip label={value} state={state} />
</>
)
}
@@ -0,0 +1,43 @@
/**
* Foreground class for a brand icon rendered inside its colored block tile.
*
* This is a self-contained mirror of `apps/sim/lib/colors` +
* `getTileIconColorClass` (apps/sim/blocks/icon-color.ts). The renderer package
* is intentionally isolated and must not import app code, so the small bit of
* brightness math it needs lives here. Keep the threshold and behavior in sync
* with the canonical helper.
*
* Block icons are increasingly drawn with `fill='currentColor'`, so a tile must
* give them a foreground that contrasts the (fixed, non-theme) brand
* background: white on dark tiles, near-black on clearly light tiles. Hardcoded
* multi-color icons ignore the class and keep their own fills.
*/
/** ITU-R BT.601 perceived brightness (01) of a `#rgb`/`#rrggbb` color, else null. */
function perceivedBrightness(color: string): number | null {
const hex = color.trim().replace(/['"#]/g, '').toLowerCase()
let r: number
let g: number
let b: number
if (/^[0-9a-f]{3}$/.test(hex)) {
r = Number.parseInt(hex[0] + hex[0], 16)
g = Number.parseInt(hex[1] + hex[1], 16)
b = Number.parseInt(hex[2] + hex[2], 16)
} else if (/^[0-9a-f]{6}$/.test(hex)) {
r = Number.parseInt(hex.slice(0, 2), 16)
g = Number.parseInt(hex.slice(2, 4), 16)
b = Number.parseInt(hex.slice(4, 6), 16)
} else {
return null
}
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
}
/** Tiles brighter than this flip their icon foreground to near-black. */
const LIGHT_TILE_THRESHOLD = 0.75
/** `text-white` on dark/unknown tiles, `text-black` on clearly light tiles. */
export function tileIconColorClass(bgColor: string | null | undefined): string {
const brightness = bgColor ? perceivedBrightness(bgColor) : null
return brightness !== null && brightness > LIGHT_TILE_THRESHOLD ? 'text-black' : 'text-white'
}
@@ -0,0 +1,247 @@
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'
import { getEmbedInfo } from '@sim/utils/media-embed'
const EMBED_SCALE = 0.78
const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%`
/**
* 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,270 @@
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 { tileIconColorClass } from '../lib/tile-icon-color'
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={cn(
'size-[16px]',
isEnabled ? tileIconColorClass(blockIconBg) : 'text-[var(--text-icon)]'
)}
/>
</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
@@ -0,0 +1,41 @@
import { cn } from '@sim/emcn'
import { OverflowSpan } from '../lib/overflow-span'
/**
* Props for the pure subblock summary row. The container resolves the value —
* including all selector-name hydration (credentials, knowledge bases, tables,
* MCP servers/tools, sub-workflows, skills, …) — and passes only the final
* strings, so this view carries no store, query, or registry coupling.
*/
export interface SubBlockRowViewProps {
/** Subblock label, rendered capitalized on the left. */
title: string
/** Resolved display value on the right; `undefined` hides the value span. */
displayValue?: string
/** Render the value in a monospace font (e.g. filter expressions). */
isMonospace?: boolean
}
/**
* Pure renderer for a collapsed block's subblock summary row: a capitalized
* title and its resolved display value.
*/
export function SubBlockRowView({ title, displayValue, isMonospace }: SubBlockRowViewProps) {
return (
<div className='flex items-center gap-2'>
<OverflowSpan
value={title}
className='min-w-0 truncate text-[var(--text-tertiary)] text-sm capitalize'
/>
{displayValue !== undefined && (
<OverflowSpan
value={displayValue}
className={cn(
'flex-1 truncate text-right text-[var(--text-primary)] text-sm',
isMonospace && 'font-mono'
)}
/>
)}
</div>
)
}
@@ -0,0 +1,500 @@
import type { ComponentType, ReactNode, Ref } from 'react'
import { Badge, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
import { Handle, Position } from 'reactflow'
import { HANDLE_POSITIONS } from '../dimensions'
import { OverflowSpan } from '../lib/overflow-span'
import { tileIconColorClass } from '../lib/tile-icon-color'
import type { BlockRunStatus } from '../types'
import { SubBlockRowView } from './sub-block-row-view'
/**
* Reusable styles and positioning for Handle components.
*/
const getHandleClasses = (position: 'left' | 'right' | 'top' | 'bottom', isError = false) => {
const baseClasses = '!z-[0] !cursor-crosshair !border-none !transition-[colors] !duration-150'
const colorClasses = isError ? '!bg-[var(--text-error)]' : '!bg-[var(--workflow-edge)]'
const positionClasses = {
left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover-hover:!left-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-l-full',
right:
'!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover-hover:!right-[-11px] hover-hover:!w-[10px] hover-hover:!rounded-r-full',
top: '!top-[-8px] !h-[7px] !w-5 !rounded-t-[2px] !rounded-b-none hover-hover:!top-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-t-full',
bottom:
'!bottom-[-8px] !h-[7px] !w-5 !rounded-b-[2px] !rounded-t-none hover-hover:!bottom-[-11px] hover-hover:!h-[10px] hover-hover:!rounded-b-full',
}
return cn(baseClasses, colorClasses, positionClasses[position])
}
const getHandleStyle = (position: 'horizontal' | 'vertical') => {
if (position === 'horizontal') {
return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
}
return { left: '50%', transform: 'translateX(-50%)' }
}
/**
* Props for the pure workflow-block renderer.
*
* Presentation comes from the editor (or docs) container: visual flags
* (enabled/locked/pending/ring), handle topology (condition/router rows), and
* the resolved badge state (child-deploy, schedule, webhook) are all computed
* upstream and passed in. The block icon, content rows, and editor-only action
* bar are injected as slots so the pure renderer carries no store, query, or
* registry coupling.
*/
export interface WorkflowBlockViewProps {
/** Block identity and visual state, resolved by the container. */
id: string
type: string
name: string
isPending?: boolean
isEnabled: boolean
isLocked: boolean
hasRing: boolean
ringStyles: string
/** Resolved run-path outcome, drives the muted-name styling. */
runPathStatus?: BlockRunStatus
/** Block icon component and its background color. */
Icon: ComponentType<{ className?: string }>
iconBgColor: string
/** Handle orientation and topology, resolved by the container. */
horizontalHandles: boolean
shouldShowDefaultHandles: boolean
hasContentBelowHeader: boolean
conditionRows: { id: string; title: string; value: string }[]
routerRows: { id: string; value: string }[]
/** Router 'Context' summary-row value (router_v2 only). */
routerContextValue?: string
/** Connection-cycle guard; reads fresh edge state on every call. */
wouldCreateConnectionCycle: (source: string, target: string) => boolean
/** Child-workflow deploy badge state — editor-only; omit in read-only contexts. */
isWorkflowSelector?: boolean
childWorkflowId?: string
childIsDeployed?: boolean | null
childNeedsRedeploy?: boolean
isDeploying?: boolean
canAdmin?: boolean
onDeployChild?: () => void
/** Schedule badge state — editor-only; omit in read-only contexts. */
shouldShowScheduleBadge?: boolean
scheduleIsDisabled?: boolean
onReactivateSchedule?: () => void
/** Webhook badge state — editor-only; omit in read-only contexts. */
showWebhookIndicator?: boolean
webhookProvider?: string
webhookPath?: string
webhookProviderName?: string
isWebhookConfigured?: boolean
isWebhookDisabled?: boolean
webhookId?: string
onReactivateWebhook?: () => void
/** Selects this block in the editor panel. */
onSelect: () => void
/** Ref attached to the inner content container. */
contentRef?: Ref<HTMLDivElement>
/** Editor-only action bar; omit in read-only / preview contexts. */
actionBar?: ReactNode
/**
* Non-branch collapsed subblock summary rows, built by the container.
* Condition/router/error rows are rendered by the view itself from
* conditionRows/routerRows.
*/
rows: ReactNode
}
/**
* Pure renderer for a workflow block: a header (icon, name, status badges), an
* optional content section of collapsed subblock rows, and the full handle
* topology (default/condition/router/error connection handles).
*/
export function WorkflowBlockView({
id,
type,
name,
isPending,
isEnabled,
isLocked,
hasRing,
ringStyles,
runPathStatus,
Icon,
iconBgColor,
horizontalHandles,
shouldShowDefaultHandles,
hasContentBelowHeader,
conditionRows,
routerRows,
routerContextValue,
wouldCreateConnectionCycle,
isWorkflowSelector,
childWorkflowId,
childIsDeployed,
childNeedsRedeploy,
isDeploying,
canAdmin,
onDeployChild,
shouldShowScheduleBadge,
scheduleIsDisabled,
onReactivateSchedule,
showWebhookIndicator,
webhookProvider,
webhookPath,
webhookProviderName,
isWebhookConfigured,
isWebhookDisabled,
webhookId,
onReactivateWebhook,
onSelect,
contentRef,
actionBar,
rows,
}: WorkflowBlockViewProps) {
return (
<div className='group relative'>
<div
ref={contentRef}
role='button'
tabIndex={0}
onClick={onSelect}
onKeyDown={(event) => handleKeyboardActivation(event, onSelect)}
className={cn(
'workflow-drag-handle relative z-[20] w-[250px] cursor-grab select-none rounded-lg border border-[var(--border-1)] bg-[var(--surface-2)] [&:active]:cursor-grabbing'
)}
>
{isPending && (
<div className='-top-6 -translate-x-1/2 absolute left-1/2 z-10 transform rounded-t-md bg-amber-500 px-2 py-0.5 text-white text-xs'>
Next Step
</div>
)}
{actionBar}
{shouldShowDefaultHandles && (
<Handle
type='target'
position={horizontalHandles ? Position.Left : Position.Top}
id='target'
className={getHandleClasses(horizontalHandles ? 'left' : 'top')}
style={getHandleStyle(horizontalHandles ? 'horizontal' : 'vertical')}
data-nodeid={id}
data-handleid='target'
isConnectableStart={false}
isConnectableEnd={true}
isValidConnection={(connection) => {
if (connection.source === id) return false
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
}}
/>
)}
<div
className={cn(
'flex items-center justify-between p-2',
hasContentBelowHeader && 'border-[var(--border-1)] border-b'
)}
>
<div className='relative z-10 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={{
background: isEnabled ? iconBgColor : 'gray',
}}
>
<Icon
className={cn(
'size-[16px]',
isEnabled ? tileIconColorClass(iconBgColor) : 'text-[var(--text-icon)]'
)}
/>
</div>
<OverflowSpan
value={name}
className={cn(
'truncate font-medium text-md',
!isEnabled && runPathStatus !== 'success' && 'text-[var(--text-muted)]'
)}
/>
</div>
<div className='relative z-10 flex flex-shrink-0 items-center gap-1'>
{isWorkflowSelector &&
childWorkflowId &&
typeof childIsDeployed === 'boolean' &&
(!childIsDeployed || childNeedsRedeploy) && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant={!childIsDeployed ? 'red' : 'amber'}
className={canAdmin ? 'cursor-pointer' : 'cursor-not-allowed'}
dot
onClick={(e) => {
e.stopPropagation()
onDeployChild?.()
}}
>
{isDeploying ? 'Deploying...' : !childIsDeployed ? 'undeployed' : 'redeploy'}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<span className='text-sm'>
{!canAdmin
? 'Admin permission required to deploy'
: !childIsDeployed
? 'Click to deploy'
: 'Click to redeploy'}
</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{!isEnabled && !isLocked && <Badge variant='gray-secondary'>disabled</Badge>}
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
{type === 'schedule' && shouldShowScheduleBadge && scheduleIsDisabled && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant='amber'
className='cursor-pointer'
dot
onClick={(e) => {
e.stopPropagation()
onReactivateSchedule?.()
}}
>
disabled
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<span className='text-sm'>Click to reactivate</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{showWebhookIndicator && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge variant='orange' dot>
Webhook
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-[300px]'>
{webhookProvider && webhookPath ? (
<>
<p className='text-sm'>{webhookProviderName} Webhook</p>
<p className='mt-1 text-muted-foreground text-xs'>Path: {webhookPath}</p>
</>
) : (
<p className='text-muted-foreground text-sm'>
This workflow is triggered by a webhook.
</p>
)}
</Tooltip.Content>
</Tooltip.Root>
)}
{isWebhookConfigured && isWebhookDisabled && webhookId && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Badge
variant='amber'
className='cursor-pointer'
dot
onClick={(e) => {
e.stopPropagation()
onReactivateWebhook?.()
}}
>
disabled
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<span className='text-sm'>Click to reactivate</span>
</Tooltip.Content>
</Tooltip.Root>
)}
{/* {isActive && (
<div className='mr-0.5 ml-2 flex size-[16px] items-center justify-center'>
<div
className='h-full w-full animate-spin-slow rounded-full border-[2.5px] border-[rgba(255,102,0,0.25)] border-t-[var(--warning)]'
aria-hidden='true'
/>
</div>
)} */}
</div>
</div>
{hasContentBelowHeader && (
<div className='flex flex-col gap-2 p-2'>
{type === 'condition' ? (
conditionRows.map((cond) => (
<SubBlockRowView key={cond.id} title={cond.title} displayValue={cond.value} />
))
) : type === 'router_v2' ? (
<>
<SubBlockRowView key='context' title='Context' displayValue={routerContextValue} />
{routerRows.map((route, index) => (
<SubBlockRowView
key={route.id}
title={`Route ${index + 1}`}
displayValue={route.value}
/>
))}
</>
) : (
rows
)}
{shouldShowDefaultHandles && <SubBlockRowView title='error' />}
</div>
)}
{type === 'condition' && (
<>
{conditionRows.map((cond, condIndex) => {
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y +
condIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${cond.id}`}
type='source'
position={Position.Right}
id={`condition-${cond.id}`}
className={getHandleClasses('right')}
style={{ top: `${topOffset}px`, transform: 'translateY(-50%)' }}
data-nodeid={id}
data-handleid={`condition-${cond.id}`}
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => {
if (connection.target === id) return false
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
}}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={getHandleClasses('right', true)}
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
data-nodeid={id}
data-handleid='error'
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => {
if (connection.target === id) return false
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
}}
/>
</>
)}
{type === 'router_v2' && (
<>
{routerRows.map((route, routeIndex) => {
// +1 row offset for context row at the top
const topOffset =
HANDLE_POSITIONS.CONDITION_START_Y +
(routeIndex + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT
return (
<Handle
key={`handle-${route.id}`}
type='source'
position={Position.Right}
id={`router-${route.id}`}
className={getHandleClasses('right')}
style={{ top: `${topOffset}px`, transform: 'translateY(-50%)' }}
data-nodeid={id}
data-handleid={`router-${route.id}`}
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => {
if (connection.target === id) return false
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
}}
/>
)
})}
<Handle
type='source'
position={Position.Right}
id='error'
className={getHandleClasses('right', true)}
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
data-nodeid={id}
data-handleid='error'
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => {
if (connection.target === id) return false
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
}}
/>
</>
)}
{type !== 'condition' && type !== 'router_v2' && type !== 'response' && (
<>
<Handle
type='source'
position={horizontalHandles ? Position.Right : Position.Bottom}
id='source'
className={getHandleClasses(horizontalHandles ? 'right' : 'bottom')}
style={getHandleStyle(horizontalHandles ? 'horizontal' : 'vertical')}
data-nodeid={id}
data-handleid='source'
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => {
if (connection.target === id) return false
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
}}
/>
{shouldShowDefaultHandles && (
<Handle
type='source'
position={Position.Right}
id='error'
className={getHandleClasses('right', true)}
style={{
right: '-7px',
top: 'auto',
bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`,
transform: 'translateY(50%)',
}}
data-nodeid={id}
data-handleid='error'
isConnectableStart={true}
isConnectableEnd={false}
isValidConnection={(connection) => {
if (connection.target === id) return false
return !wouldCreateConnectionCycle(connection.source!, connection.target!)
}}
/>
)}
</>
)}
{hasRing && (
<div className={cn('pointer-events-none absolute inset-0 z-40 rounded-lg', ringStyles)} />
)}
</div>
</div>
)
}
+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"]
}