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
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:
@@ -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
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (0–1) 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@sim/tsconfig/library.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user