feat(notes): add notes (#1898)

* Notes v1

* v2

* Lint

* Consolidate into hook

* Simplify workflow code

* Fix hitl casing

* Don't allow edges in note block and explicitly exclude from executor

* Add hooks

* Consolidate hook

* Consolidate utils checks

* Consolidate dimensions
This commit is contained in:
Siddharth Ganesan
2025-11-11 13:31:14 -08:00
committed by GitHub
parent 7c398e64dc
commit 1cce486442
18 changed files with 621 additions and 148 deletions

View File

@@ -0,0 +1,204 @@
import { memo, useCallback, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import type { NodeProps } from 'reactflow'
import remarkGfm from 'remark-gfm'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useBlockCore } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
BLOCK_DIMENSIONS,
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { ActionBar } from '../workflow-block/components'
import type { WorkflowBlockProps } from '../workflow-block/types'
interface NoteBlockNodeData extends WorkflowBlockProps {}
/**
* Extract string value from subblock value object or primitive
*/
function extractFieldValue(rawValue: unknown): string | undefined {
if (typeof rawValue === 'string') return rawValue
if (rawValue && typeof rawValue === 'object' && 'value' in rawValue) {
const candidate = (rawValue as { value?: unknown }).value
return typeof candidate === 'string' ? candidate : undefined
}
return undefined
}
/**
* Compact markdown renderer for note blocks with tight spacing
*/
const NoteMarkdown = memo(function NoteMarkdown({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => <p className='mb-0 text-[#E5E5E5] text-sm'>{children}</p>,
h1: ({ children }) => (
<h1 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-lg'>{children}</h1>
),
h2: ({ children }) => (
<h2 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-base'>{children}</h2>
),
h3: ({ children }) => (
<h3 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-sm'>{children}</h3>
),
h4: ({ children }) => (
<h4 className='mt-0 mb-[-2px] font-semibold text-[#E5E5E5] text-xs'>{children}</h4>
),
ul: ({ children }) => (
<ul className='-mt-[2px] mb-0 list-disc pl-4 text-[#E5E5E5] text-sm'>{children}</ul>
),
ol: ({ children }) => (
<ol className='-mt-[2px] mb-0 list-decimal pl-4 text-[#E5E5E5] text-sm'>{children}</ol>
),
li: ({ children }) => <li className='mb-0'>{children}</li>,
code: ({ inline, children }: any) =>
inline ? (
<code className='rounded bg-[#393939] px-1 py-0.5 text-[#F59E0B] text-xs'>
{children}
</code>
) : (
<code className='block rounded bg-[#1A1A1A] p-2 text-[#E5E5E5] text-xs'>
{children}
</code>
),
a: ({ href, children }) => (
<a
href={href}
target='_blank'
rel='noopener noreferrer'
className='text-[#33B4FF] underline-offset-2 hover:underline'
>
{children}
</a>
),
strong: ({ children }) => <strong className='font-semibold text-white'>{children}</strong>,
em: ({ children }) => <em className='text-[#B8B8B8]'>{children}</em>,
blockquote: ({ children }) => (
<blockquote className='m-0 border-[#F59E0B] border-l-2 pl-3 text-[#B8B8B8] italic'>
{children}
</blockquote>
),
}}
>
{content}
</ReactMarkdown>
)
})
export const NoteBlock = memo(function NoteBlock({ id, data }: NodeProps<NoteBlockNodeData>) {
const { type, config, name } = data
const { activeWorkflowId, isEnabled, isFocused, handleClick, hasRing, ringStyles } = useBlockCore(
{ blockId: id, data }
)
const storedValues = useSubBlockStore(
useCallback(
(state) => {
if (!activeWorkflowId) return undefined
return state.workflowValues[activeWorkflowId]?.[id]
},
[activeWorkflowId, id]
)
)
const noteValues = useMemo(() => {
if (data.isPreview && data.subBlockValues) {
const extractedPreviewFormat = extractFieldValue(data.subBlockValues.format)
const extractedPreviewContent = extractFieldValue(data.subBlockValues.content)
return {
format: typeof extractedPreviewFormat === 'string' ? extractedPreviewFormat : 'plain',
content: typeof extractedPreviewContent === 'string' ? extractedPreviewContent : '',
}
}
const format = extractFieldValue(storedValues?.format)
const content = extractFieldValue(storedValues?.content)
return {
format: typeof format === 'string' ? format : 'plain',
content: typeof content === 'string' ? content : '',
}
}, [data.isPreview, data.subBlockValues, storedValues])
const content = noteValues.content ?? ''
const isEmpty = content.trim().length === 0
const showMarkdown = noteValues.format === 'markdown' && !isEmpty
const userPermissions = useUserPermissionsContext()
/**
* Calculate deterministic dimensions based on content structure.
* Uses fixed width and computed height to avoid ResizeObserver jitter.
*/
useBlockDimensions({
blockId: id,
calculateDimensions: () => {
const contentHeight = isEmpty
? BLOCK_DIMENSIONS.NOTE_MIN_CONTENT_HEIGHT
: BLOCK_DIMENSIONS.NOTE_BASE_CONTENT_HEIGHT
const calculatedHeight =
BLOCK_DIMENSIONS.HEADER_HEIGHT + BLOCK_DIMENSIONS.NOTE_CONTENT_PADDING + contentHeight
return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: calculatedHeight }
},
dependencies: [isEmpty],
})
return (
<div className='group relative'>
<div
className={cn(
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] bg-[#232323]'
)}
onClick={handleClick}
>
<ActionBar blockId={id} blockType={type} disabled={!userPermissions.canEdit} />
<div
className='note-drag-handle flex cursor-grab items-center justify-between border-[#393939] border-b p-[8px] [&:active]:cursor-grabbing'
onMouseDown={(event) => {
event.stopPropagation()
}}
>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
<div
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
style={{ backgroundColor: isEnabled ? config.bgColor : 'gray' }}
>
<config.icon className='h-[16px] w-[16px] text-white' />
</div>
<span
className={cn('font-medium text-[16px]', !isEnabled && 'truncate text-[#808080]')}
title={name}
>
{name}
</span>
</div>
</div>
<div className='relative px-[12px] pt-[6px] pb-[8px]'>
<div className='relative whitespace-pre-wrap break-words'>
{isEmpty ? (
<p className='text-[#868686] text-sm italic'>Add a note...</p>
) : showMarkdown ? (
<NoteMarkdown content={content} />
) : (
<p className='whitespace-pre-wrap text-[#E5E5E5] text-sm leading-relaxed'>
{content}
</p>
)}
</div>
</div>
{hasRing && (
<div
className={cn('pointer-events-none absolute inset-0 z-40 rounded-[8px]', ringStyles)}
/>
)}
</div>
</div>
)
})

View File

@@ -3,6 +3,7 @@ import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-r
import { Button, Duplicate, Tooltip, Trash2 } from '@/components/emcn'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { supportsHandles } from '@/executor/consts'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -146,29 +147,31 @@ export const ActionBar = memo(
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => {
if (!disabled) {
collaborativeToggleBlockHandles(blockId)
}
}}
className='h-[30px] w-[30px] rounded-[8px] bg-[#363636] p-0 text-[#868686] hover:bg-[#33B4FF] hover:text-[#1B1B1B] dark:text-[#868686] dark:hover:bg-[#33B4FF] dark:hover:text-[#1B1B1B]'
disabled={disabled}
>
{horizontalHandles ? (
<ArrowLeftRight className='h-[14px] w-[14px]' />
) : (
<ArrowUpDown className='h-[14px] w-[14px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='right'>
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</Tooltip.Content>
</Tooltip.Root>
{supportsHandles(blockType) && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => {
if (!disabled) {
collaborativeToggleBlockHandles(blockId)
}
}}
className='h-[30px] w-[30px] rounded-[8px] bg-[#363636] p-0 text-[#868686] hover:bg-[#33B4FF] hover:text-[#1B1B1B] dark:text-[#868686] dark:hover:bg-[#33B4FF] dark:hover:text-[#1B1B1B]'
disabled={disabled}
>
{horizontalHandles ? (
<ArrowLeftRight className='h-[14px] w-[14px]' />
) : (
<ArrowUpDown className='h-[14px] w-[14px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='right'>
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
</Tooltip.Content>
</Tooltip.Root>
)}
{!isStarterBlock && (
<Tooltip.Root>

View File

@@ -1,29 +1,25 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'next/navigation'
import { Handle, type NodeProps, Position, useUpdateNodeInternals } from 'reactflow'
import { Handle, type NodeProps, Position } from 'reactflow'
import { Badge } from '@/components/emcn/components/badge/badge'
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
import { getEnv, isTruthy } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useBlockCore } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
BLOCK_DIMENSIONS,
useBlockDimensions,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
import type { SubBlockConfig } from '@/blocks/types'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useCredentialDisplay } from '@/hooks/use-credential-display'
import { useDisplayName } from '@/hooks/use-display-name'
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import { useCurrentWorkflow } from '../../hooks'
import { ActionBar, Connections } from './components'
import {
useBlockProperties,
useBlockState,
useChildWorkflow,
useScheduleInfo,
useWebhookInfo,
} from './hooks'
import { useBlockProperties, useChildWorkflow, useScheduleInfo, useWebhookInfo } from './hooks'
import type { WorkflowBlockProps } from './types'
import { getProviderName, shouldSkipBlockRender } from './utils'
@@ -267,20 +263,25 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const { type, config, name, isPending } = data
const contentRef = useRef<HTMLDivElement>(null)
const updateNodeInternals = useUpdateNodeInternals()
const params = useParams()
const currentWorkflowId = params.workflowId as string
const workspaceId = params.workspaceId as string
const currentWorkflow = useCurrentWorkflow()
const currentBlock = currentWorkflow.getBlockById(id)
const { isEnabled, isActive, diffStatus, isDeletedBlock } = useBlockState(
id,
const {
currentWorkflow,
data
)
activeWorkflowId,
isEnabled,
isActive,
diffStatus,
isDeletedBlock,
isFocused,
handleClick,
hasRing,
ringStyles,
} = useBlockCore({ blockId: id, data, isPending })
const currentBlock = currentWorkflow.getBlockById(id)
const { horizontalHandles, blockHeight, blockWidth, displayAdvancedMode, displayTriggerMode } =
useBlockProperties(
@@ -368,24 +369,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
}
}, [id, collaborativeSetSubblockValue])
const updateBlockLayoutMetrics = useWorkflowStore((state) => state.updateBlockLayoutMetrics)
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isFocused = currentBlockId === id
const currentStoreBlock = currentWorkflow.getBlockById(id)
const isStarterBlock = type === 'starter'
const isWebhookTriggerBlock = type === 'webhook' || type === 'generic_webhook'
/**
* Update node internals when handles change to ensure ReactFlow
* correctly calculates connection points.
*/
useEffect(() => {
updateNodeInternals(id)
}, [id, horizontalHandles, updateNodeInternals])
/**
* Subscribe to this block's subblock values to track changes for conditional rendering
* of subblocks based on their conditions.
@@ -597,59 +585,45 @@ export const WorkflowBlock = memo(function WorkflowBlock({
/**
* Compute and publish deterministic layout metrics for workflow blocks.
* This avoids ResizeObserver/animation-frame jitter and prevents initial "jump".
*
* Height model:
* - Fixed header height: 40px
* - Content padding when present: 16px (8 top + 8 bottom)
* - Row height: 29px per rendered row (subblock rows, condition rows, plus error row if present)
*
* Width is a fixed 250px for workflow blocks.
*/
useEffect(() => {
// Only workflow blocks (non-subflow) render here, width is constant
const FIXED_WIDTH = 250
const HEADER_HEIGHT = 40
const CONTENT_PADDING = 16
const ROW_HEIGHT = 29
useBlockDimensions({
blockId: id,
calculateDimensions: () => {
const shouldShowDefaultHandles =
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
const shouldShowDefaultHandles =
config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode
const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles
// Count rows based on block type and whether default handles section is shown
const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0
// Count rows based on block type and whether default handles section is shown
const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0
let rowsCount = 0
if (type === 'condition') {
rowsCount = conditionRows.length + defaultHandlesRow
} else {
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
rowsCount = subblockRowCount + defaultHandlesRow
}
let rowsCount = 0
if (type === 'condition') {
rowsCount = conditionRows.length + defaultHandlesRow
} else {
const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0)
rowsCount = subblockRowCount + defaultHandlesRow
}
const contentHeight = hasContentBelowHeader
? BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING +
rowsCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT
: 0
const calculatedHeight = Math.max(
BLOCK_DIMENSIONS.HEADER_HEIGHT + contentHeight,
BLOCK_DIMENSIONS.MIN_HEIGHT
)
const contentHeight = hasContentBelowHeader ? CONTENT_PADDING + rowsCount * ROW_HEIGHT : 0
const calculatedHeight = Math.max(HEADER_HEIGHT + contentHeight, 100)
const prevHeight =
typeof currentStoreBlock?.height === 'number' ? currentStoreBlock.height : undefined
const prevWidth = 250 // fixed across the app for workflow blocks
if (prevHeight !== calculatedHeight || prevWidth !== FIXED_WIDTH) {
updateBlockLayoutMetrics(id, { width: FIXED_WIDTH, height: calculatedHeight })
updateNodeInternals(id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
id,
type,
config.category,
displayTriggerMode,
subBlockRows.length,
conditionRows.length,
currentStoreBlock?.height,
updateBlockLayoutMetrics,
updateNodeInternals,
])
return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: calculatedHeight }
},
dependencies: [
type,
config.category,
displayTriggerMode,
subBlockRows.length,
conditionRows.length,
horizontalHandles,
],
})
const showWebhookIndicator = (isStarterBlock || isWebhookTriggerBlock) && isWebhookConfigured
const shouldShowScheduleBadge =
@@ -657,35 +631,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({
const userPermissions = useUserPermissionsContext()
const isWorkflowSelector = type === 'workflow' || type === 'workflow_input'
/**
* Determine the ring styling based on block state priority:
* 1. Active (executing) - purple ring with pulse animation
* 2. Pending (next step) - orange ring
* 3. Focused (selected in editor) - blue ring
* 4. Diff status (version comparison) - green/orange/red ring
*/
const hasRing =
isActive ||
isPending ||
isFocused ||
diffStatus === 'new' ||
diffStatus === 'edited' ||
isDeletedBlock
const ringStyles = cn(
hasRing && 'ring-[1.75px]',
isActive && 'ring-[#8C10FF] animate-pulse-ring',
isPending && 'ring-[#FF6600]',
isFocused && 'ring-[#33B4FF]',
diffStatus === 'new' && 'ring-[#22C55F]',
diffStatus === 'edited' && 'ring-[#FF6600]',
isDeletedBlock && 'ring-[#EF4444]'
)
return (
<div className='group relative'>
<div
ref={contentRef}
onClick={() => setCurrentBlockId(id)}
onClick={handleClick}
className={cn(
'relative z-[20] w-[250px] cursor-default select-none rounded-[8px] bg-[#232323]'
)}

View File

@@ -1,4 +1,6 @@
export { useAutoLayout } from './use-auto-layout'
export { useBlockCore } from './use-block-core'
export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions'
export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow'
export { useNodeUtilities } from './use-node-utilities'
export { useScrollManagement } from './use-scroll-management'

View File

@@ -0,0 +1,82 @@
import { useCallback, useMemo } from 'react'
import { cn } from '@/lib/utils'
import { usePanelEditorStore } from '@/stores/panel-new/editor/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useBlockState } from '../components/workflow-block/hooks'
import type { WorkflowBlockProps } from '../components/workflow-block/types'
import { useCurrentWorkflow } from './use-current-workflow'
interface UseBlockCoreOptions {
blockId: string
data: WorkflowBlockProps
isPending?: boolean
}
/**
* Consolidated hook for core block functionality shared across all block types.
* Combines workflow state, block state, focus, and ring styling.
*/
export function useBlockCore({ blockId, data, isPending = false }: UseBlockCoreOptions) {
// Workflow context
const currentWorkflow = useCurrentWorkflow()
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
// Block state (enabled, active, diff status, deleted)
const { isEnabled, isActive, diffStatus, isDeletedBlock } = useBlockState(
blockId,
currentWorkflow,
data
)
// Focus management
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isFocused = currentBlockId === blockId
const handleClick = useCallback(() => {
setCurrentBlockId(blockId)
}, [blockId, setCurrentBlockId])
// Ring styling based on all states
const { hasRing, ringStyles } = useMemo(() => {
const hasRing =
isActive ||
isPending ||
isFocused ||
diffStatus === 'new' ||
diffStatus === 'edited' ||
isDeletedBlock
const ringStyles = cn(
hasRing && 'ring-[1.75px]',
isActive && 'ring-[#8C10FF] animate-pulse-ring',
isPending && 'ring-[#FF6600]',
isFocused && 'ring-[#33B4FF]',
diffStatus === 'new' && 'ring-[#22C55F]',
diffStatus === 'edited' && 'ring-[#FF6600]',
isDeletedBlock && 'ring-[#EF4444]'
)
return { hasRing, ringStyles }
}, [isActive, isPending, isFocused, diffStatus, isDeletedBlock])
return {
// Workflow context
currentWorkflow,
activeWorkflowId,
// Block state
isEnabled,
isActive,
diffStatus,
isDeletedBlock,
// Focus
isFocused,
handleClick,
// Ring styling
hasRing,
ringStyles,
}
}

View File

@@ -0,0 +1,63 @@
import { useEffect, useRef } from 'react'
import { useUpdateNodeInternals } from 'reactflow'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
interface BlockDimensions {
width: number
height: number
}
interface UseBlockDimensionsOptions {
blockId: string
calculateDimensions: () => BlockDimensions
dependencies: React.DependencyList
}
/**
* Shared block dimension constants
*/
export const BLOCK_DIMENSIONS = {
FIXED_WIDTH: 250,
HEADER_HEIGHT: 40,
MIN_HEIGHT: 100,
// Workflow blocks
WORKFLOW_CONTENT_PADDING: 16,
WORKFLOW_ROW_HEIGHT: 29,
// Note blocks
NOTE_CONTENT_PADDING: 14,
NOTE_MIN_CONTENT_HEIGHT: 20,
NOTE_BASE_CONTENT_HEIGHT: 60,
} as const
/**
* Hook to manage deterministic block dimensions without ResizeObserver.
* Calculates dimensions based on content structure and updates the store.
*
* @param options - Configuration for dimension calculation
* @param options.blockId - The ID of the block
* @param options.calculateDimensions - Function that returns current dimensions
* @param options.dependencies - Dependencies that trigger recalculation
*/
export function useBlockDimensions({
blockId,
calculateDimensions,
dependencies,
}: UseBlockDimensionsOptions) {
const updateNodeInternals = useUpdateNodeInternals()
const updateBlockLayoutMetrics = useWorkflowStore((state) => state.updateBlockLayoutMetrics)
const previousDimensions = useRef<BlockDimensions | null>(null)
useEffect(() => {
const dimensions = calculateDimensions()
const previous = previousDimensions.current
if (!previous || previous.width !== dimensions.width || previous.height !== dimensions.height) {
previousDimensions.current = dimensions
updateBlockLayoutMetrics(blockId, dimensions)
updateNodeInternals(blockId)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blockId, updateBlockLayoutMetrics, updateNodeInternals, ...dependencies])
}

View File

@@ -18,6 +18,7 @@ import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
import { Chat } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat'
import { UserAvatarStack } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/user-avatar-stack/user-avatar-stack'
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/panel-new'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal'
@@ -37,6 +38,7 @@ import {
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import { getBlock } from '@/blocks'
import { useSocket } from '@/contexts/socket-context'
import { isAnnotationOnlyBlock } from '@/executor/consts'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useWorkspacePermissions } from '@/hooks/use-workspace-permissions'
@@ -55,6 +57,7 @@ const logger = createLogger('Workflow')
// Define custom node and edge types - memoized outside component to prevent re-creation
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
noteBlock: NoteBlock,
subflowNode: SubflowNodeComponent,
}
const edgeTypes: EdgeTypes = {
@@ -1298,13 +1301,17 @@ const WorkflowContent = React.memo(() => {
const isActive = activeBlockIds.has(block.id)
const isPending = isDebugging && pendingBlocks.includes(block.id)
// Both note blocks and workflow blocks use deterministic dimensions
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
const dragHandle = block.type === 'note' ? '.note-drag-handle' : '.workflow-drag-handle'
// Create stable node object - React Flow will handle shallow comparison
nodeArray.push({
id: block.id,
type: 'workflowBlock',
type: nodeType,
position,
parentId: block.data?.parentId,
dragHandle: '.workflow-drag-handle',
dragHandle,
extent: (() => {
// Clamp children to subflow body (exclude header)
const parentId = block.data?.parentId as string | undefined
@@ -1332,8 +1339,9 @@ const WorkflowContent = React.memo(() => {
isPending,
},
// Include dynamic dimensions for container resizing calculations (must match rendered size)
width: 250, // Standard width - matches w-[250px] in workflow-block.tsx
height: Math.max(block.height || 100, 100), // Use actual height with minimum
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
width: 250, // Standard width for both block types
height: Math.max(block.height || 100, 100), // Use calculated height with minimum
})
})
@@ -1440,6 +1448,14 @@ const WorkflowContent = React.memo(() => {
if (!sourceNode || !targetNode) return
// Prevent connections to/from annotation-only blocks (non-executable)
if (
isAnnotationOnlyBlock(sourceNode.data?.type) ||
isAnnotationOnlyBlock(targetNode.data?.type)
) {
return
}
// Prevent incoming connections to trigger blocks (webhook, schedule, etc.)
if (targetNode.data?.config?.category === 'triggers') {
return

View File

@@ -15,6 +15,7 @@ import 'reactflow/dist/style.css'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
@@ -39,6 +40,7 @@ interface WorkflowPreviewProps {
// Define node types - the components now handle preview mode internally
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
noteBlock: NoteBlock,
subflowNode: SubflowNodeComponent,
}
@@ -179,9 +181,11 @@ export function WorkflowPreview({
const subBlocksClone = block.subBlocks ? cloneDeep(block.subBlocks) : {}
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
nodeArray.push({
id: blockId,
type: 'workflowBlock',
type: nodeType,
position: absolutePosition,
draggable: false,
data: {
@@ -204,9 +208,11 @@ export function WorkflowPreview({
const childConfig = getBlock(childBlock.type)
if (childConfig) {
const childNodeType = childBlock.type === 'note' ? 'noteBlock' : 'workflowBlock'
nodeArray.push({
id: childId,
type: 'workflowBlock',
type: childNodeType,
position: {
x: block.position.x + 50,
y: block.position.y + (childBlock.position?.y || 100),

View File

@@ -0,0 +1,46 @@
import { NoteIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
export const NoteBlock: BlockConfig = {
type: 'note',
name: 'Note',
description: 'Add contextual annotations directly onto the workflow canvas.',
longDescription:
'Use Note blocks to document decisions, share instructions, or leave context for collaborators directly on the workflow canvas. Notes support both plain text and Markdown rendering.',
category: 'blocks',
bgColor: '#F59E0B',
icon: NoteIcon,
subBlocks: [
{
id: 'format',
title: 'Display Format',
type: 'dropdown',
options: [
{ label: 'Plain text', id: 'plain' },
{ label: 'Markdown', id: 'markdown' },
],
value: () => 'plain',
description: 'Choose how the note should render on the canvas.',
},
{
id: 'content',
title: 'Content',
type: 'long-input',
rows: 8,
placeholder: 'Add context or instructions for collaborators...',
description: 'Write your note using plain text or Markdown depending on the selected format.',
},
],
tools: { access: [] },
inputs: {
format: {
type: 'string',
description: 'Render mode for the note content.',
},
content: {
type: 'string',
description: 'Text for the note.',
},
},
outputs: {},
}

View File

@@ -48,6 +48,7 @@ import { MicrosoftTeamsBlock } from '@/blocks/blocks/microsoft_teams'
import { MistralParseBlock } from '@/blocks/blocks/mistral_parse'
import { MongoDBBlock } from '@/blocks/blocks/mongodb'
import { MySQLBlock } from '@/blocks/blocks/mysql'
import { NoteBlock } from '@/blocks/blocks/note'
import { NotionBlock } from '@/blocks/blocks/notion'
import { OneDriveBlock } from '@/blocks/blocks/onedrive'
import { OpenAIBlock } from '@/blocks/blocks/openai'
@@ -145,6 +146,7 @@ export const registry: Record<string, BlockConfig> = {
mistral_parse: MistralParseBlock,
mongodb: MongoDBBlock,
mysql: MySQLBlock,
note: NoteBlock,
notion: NotionBlock,
openai: OpenAIBlock,
outlook: OutlookBlock,

View File

@@ -165,6 +165,33 @@ export function ConditionalIcon(props: SVGProps<SVGSVGElement>) {
)
}
export function NoteIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<rect
x='4'
y='3'
width='16'
height='18'
rx='2.5'
stroke='currentColor'
strokeWidth='1.5'
fill='none'
/>
<path d='M15 3H18C18.5523 3 19 3.44772 19 4V7L15 3Z' fill='currentColor' />
<path d='M8 11H15.5' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' />
<path d='M8 15H13' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' />
</svg>
)
}
export function AirplaneIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -21,6 +21,8 @@ export enum BlockType {
WAIT = 'wait',
NOTE = 'note',
SENTINEL_START = 'sentinel_start',
SENTINEL_END = 'sentinel_end',
}
@@ -31,7 +33,11 @@ export const TRIGGER_BLOCK_TYPES = [
BlockType.TRIGGER,
] as const
export const METADATA_ONLY_BLOCK_TYPES = [BlockType.LOOP, BlockType.PARALLEL] as const
export const METADATA_ONLY_BLOCK_TYPES = [
BlockType.LOOP,
BlockType.PARALLEL,
BlockType.NOTE,
] as const
export type LoopType = 'for' | 'forEach' | 'while' | 'doWhile'
@@ -247,6 +253,14 @@ export function isAgentBlockType(blockType: string | undefined): boolean {
return blockType === BlockType.AGENT
}
export function isAnnotationOnlyBlock(blockType: string | undefined): boolean {
return blockType === BlockType.NOTE
}
export function supportsHandles(blockType: string | undefined): boolean {
return !isAnnotationOnlyBlock(blockType)
}
export function getDefaultTokens() {
return {
prompt: DEFAULTS.TOKENS.PROMPT,

View File

@@ -9,6 +9,7 @@ import {
CONTAINER_PADDING_Y,
DEFAULT_CONTAINER_HEIGHT,
DEFAULT_CONTAINER_WIDTH,
filterLayoutEligibleBlockIds,
getBlocksByParent,
prepareBlockMetrics,
} from './utils'
@@ -35,13 +36,14 @@ export function layoutContainers(
logger.debug('Processing container', { parentId, childCount: childIds.length })
const layoutChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
const childBlocks: Record<string, BlockState> = {}
for (const childId of childIds) {
for (const childId of layoutChildIds) {
childBlocks[childId] = blocks[childId]
}
const childEdges = edges.filter(
(edge) => childIds.includes(edge.source) && childIds.includes(edge.target)
(edge) => layoutChildIds.includes(edge.source) && layoutChildIds.includes(edge.target)
)
if (Object.keys(childBlocks).length === 0) {

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@/lib/logs/console/logger'
import type { BlockState } from '@/stores/workflows/workflow/types'
import type { AdjustmentOptions, Edge } from './types'
import { boxesOverlap, createBoundingBox, getBlockMetrics } from './utils'
import { boxesOverlap, createBoundingBox, getBlockMetrics, shouldSkipAutoLayout } from './utils'
const logger = createLogger('AutoLayout:Incremental')
@@ -19,6 +19,14 @@ export function adjustForNewBlock(
return
}
if (shouldSkipAutoLayout(newBlock)) {
logger.debug('Skipping incremental layout for block excluded from auto layout', {
newBlockId,
type: newBlock.type,
})
return
}
const shiftSpacing = options.horizontalSpacing ?? DEFAULT_SHIFT_SPACING
const incomingEdges = edges.filter((e) => e.target === newBlockId)
@@ -78,6 +86,7 @@ export function adjustForNewBlock(
for (const [id, block] of Object.entries(blocks)) {
if (id === newBlockId) continue
if (block.data?.parentId) continue
if (shouldSkipAutoLayout(block)) continue
if (block.position.x >= newBlock.position.x) {
const blockMetrics = getBlockMetrics(block)
@@ -105,7 +114,9 @@ export function adjustForNewBlock(
}
export function compactHorizontally(blocks: Record<string, BlockState>, edges: Edge[]): void {
const blockArray = Object.values(blocks).filter((b) => !b.data?.parentId)
const blockArray = Object.values(blocks).filter(
(b) => !b.data?.parentId && !shouldSkipAutoLayout(b)
)
blockArray.sort((a, b) => a.position.x - b.position.x)

View File

@@ -5,7 +5,7 @@ import { adjustForNewBlock as adjustForNewBlockInternal, compactHorizontally } f
import { assignLayers, groupByLayer } from './layering'
import { calculatePositions } from './positioning'
import type { AdjustmentOptions, Edge, LayoutOptions, LayoutResult, Loop, Parallel } from './types'
import { getBlocksByParent, prepareBlockMetrics } from './utils'
import { filterLayoutEligibleBlockIds, getBlocksByParent, prepareBlockMetrics } from './utils'
const logger = createLogger('AutoLayout')
@@ -28,13 +28,15 @@ export function applyAutoLayout(
const { root: rootBlockIds } = getBlocksByParent(blocksCopy)
const layoutRootIds = filterLayoutEligibleBlockIds(rootBlockIds, blocksCopy)
const rootBlocks: Record<string, BlockState> = {}
for (const id of rootBlockIds) {
for (const id of layoutRootIds) {
rootBlocks[id] = blocksCopy[id]
}
const rootEdges = edges.filter(
(edge) => rootBlockIds.includes(edge.source) && rootBlockIds.includes(edge.target)
(edge) => layoutRootIds.includes(edge.source) && layoutRootIds.includes(edge.target)
)
if (Object.keys(rootBlocks).length > 0) {
@@ -102,4 +104,4 @@ export function adjustForNewBlock(
export type { LayoutOptions, LayoutResult, AdjustmentOptions, Edge, Loop, Parallel }
export type { TargetedLayoutOptions } from './targeted'
export { applyTargetedLayout, transferBlockHeights } from './targeted'
export { getBlockMetrics, isContainerType } from './utils'
export { getBlockMetrics, isContainerType, shouldSkipAutoLayout } from './utils'

View File

@@ -9,12 +9,14 @@ import {
CONTAINER_PADDING_Y,
DEFAULT_CONTAINER_HEIGHT,
DEFAULT_CONTAINER_WIDTH,
filterLayoutEligibleBlockIds,
getBlockMetrics,
getBlocksByParent,
isContainerType,
prepareBlockMetrics,
ROOT_PADDING_X,
ROOT_PADDING_Y,
shouldSkipAutoLayout,
} from './utils'
const logger = createLogger('AutoLayout:Targeted')
@@ -71,14 +73,23 @@ function layoutGroup(
const parentBlock = parentId ? blocks[parentId] : undefined
const requestedLayout = childIds.filter((id) => {
const layoutEligibleChildIds = filterLayoutEligibleBlockIds(childIds, blocks)
if (layoutEligibleChildIds.length === 0) {
if (parentBlock) {
updateContainerDimensions(parentBlock, childIds, blocks)
}
return
}
const requestedLayout = layoutEligibleChildIds.filter((id) => {
const block = blocks[id]
if (!block) return false
// Never reposition containers, only update their dimensions
if (isContainerType(block.type)) return false
return changedSet.has(id)
})
const missingPositions = childIds.filter((id) => {
const missingPositions = layoutEligibleChildIds.filter((id) => {
const block = blocks[id]
if (!block) return false
// Containers with missing positions should still get positioned
@@ -99,14 +110,14 @@ function layoutGroup(
const oldPositions = new Map<string, { x: number; y: number }>()
for (const id of childIds) {
for (const id of layoutEligibleChildIds) {
const block = blocks[id]
if (!block) continue
oldPositions.set(id, { ...block.position })
}
const layoutPositions = computeLayoutPositions(
childIds,
layoutEligibleChildIds,
blocks,
edges,
parentBlock,
@@ -125,7 +136,9 @@ function layoutGroup(
let offsetX = 0
let offsetY = 0
const anchorId = childIds.find((id) => !needsLayout.includes(id) && layoutPositions.has(id))
const anchorId = layoutEligibleChildIds.find(
(id) => !needsLayout.includes(id) && layoutPositions.has(id)
)
if (anchorId) {
const oldPos = oldPositions.get(anchorId)
@@ -272,6 +285,9 @@ function updateContainerDimensions(
for (const id of childIds) {
const child = blocks[id]
if (!child) continue
if (shouldSkipAutoLayout(child)) {
continue
}
const metrics = getBlockMetrics(child)
minX = Math.min(minX, child.position.x)

View File

@@ -18,10 +18,28 @@ function resolveNumeric(value: number | undefined, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
const AUTO_LAYOUT_EXCLUDED_TYPES = new Set(['note'])
export function isContainerType(blockType: string): boolean {
return blockType === 'loop' || blockType === 'parallel'
}
export function shouldSkipAutoLayout(block?: BlockState): boolean {
if (!block) return true
return AUTO_LAYOUT_EXCLUDED_TYPES.has(block.type)
}
export function filterLayoutEligibleBlockIds(
blockIds: string[],
blocks: Record<string, BlockState>
): string[] {
return blockIds.filter((id) => {
const block = blocks[id]
if (!block) return false
return !shouldSkipAutoLayout(block)
})
}
function getContainerMetrics(block: BlockState): BlockMetrics {
const measuredWidth = block.layout?.measuredWidth
const measuredHeight = block.layout?.measuredHeight

View File

@@ -6,6 +6,7 @@ import { getBlockOutputs } from '@/lib/workflows/block-outputs'
import { TriggerUtils } from '@/lib/workflows/triggers'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { isAnnotationOnlyBlock } from '@/executor/consts'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import {
@@ -422,6 +423,14 @@ export const useWorkflowStore = create<WorkflowStore>()(
},
addEdge: (edge: Edge) => {
// Prevent connections to/from annotation-only blocks (non-executable)
const sourceBlock = get().blocks[edge.source]
const targetBlock = get().blocks[edge.target]
if (isAnnotationOnlyBlock(sourceBlock?.type) || isAnnotationOnlyBlock(targetBlock?.type)) {
return
}
// Check for duplicate connections
const isDuplicate = get().edges.some(
(existingEdge) =>