From 44c7cc3d8321d611428f879aefb3643cb6d9af52 Mon Sep 17 00:00:00 2001 From: andresdjasso Date: Fri, 24 Jul 2026 16:23:11 -0500 Subject: [PATCH 1/3] improvement(workflow): refine canvas interactions and rendering --- apps/realtime/src/database/operations.ts | 27 + apps/realtime/src/middleware/permissions.ts | 1 + .../components/action-bar/action-bar.tsx | 512 ++--- .../components/block-menu/block-menu.tsx | 14 - .../components/workflow-block/types.ts | 4 + .../components/workflow-block/utils.ts | 2 + .../workflow-block/workflow-block.tsx | 559 +++++- .../workflow-edge/workflow-edge.tsx | 31 +- .../hooks/use-canvas-context-menu.ts | 1 - .../w/[workflowId]/utils/block-ring-utils.ts | 7 +- .../utils/workflow-execution-utils.ts | 5 + .../w/[workflowId]/workflow-constants.ts | 3 + .../[workspaceId]/w/[workflowId]/workflow.tsx | 309 +++- .../components/block/block.tsx | 158 +- .../command-items/command-items.tsx | 43 +- .../search-groups/search-groups.tsx | 1 + .../sidebar/components/search-modal/utils.ts | 2 + apps/sim/components/icons.tsx | 2 +- .../executor/execution/edge-manager.test.ts | 23 + apps/sim/executor/execution/edge-manager.ts | 5 + apps/sim/hooks/use-collaborative-workflow.ts | 21 +- apps/sim/lib/api/contracts/workflows.ts | 2 + .../workflow/edit-workflow/validation.ts | 9 +- apps/sim/lib/workflows/autolayout/core.ts | 19 +- .../blocks/deterministic-dimensions.ts | 68 +- .../workflow-block-border-mount.test.tsx | 281 +++ apps/sim/lib/workflows/comparison/compare.ts | 7 +- apps/sim/lib/workflows/defaults.ts | 1 + apps/sim/lib/workflows/diff/diff-engine.ts | 10 +- .../workflows/sanitization/json-sanitizer.ts | 7 +- apps/sim/lib/workflows/subblocks/display.ts | 12 + .../stores/workflows/workflow/store.test.ts | 18 + apps/sim/stores/workflows/workflow/store.ts | 19 + apps/sim/stores/workflows/workflow/types.ts | 1 + bun.lock | 2 + .../emcn/src/components/chip-tag/chip-tag.tsx | 63 +- packages/emcn/src/components/index.ts | 1 + .../emcn/src/components/tooltip/tooltip.tsx | 49 +- packages/realtime-protocol/src/constants.ts | 1 + packages/realtime-protocol/src/schemas.ts | 2 + .../src/factories/permission.factory.ts | 1 + packages/workflow-persistence/src/load.ts | 11 +- packages/workflow-persistence/src/save.ts | 15 +- packages/workflow-renderer/package.json | 2 + packages/workflow-renderer/src/dimensions.ts | 23 +- .../src/edge/workflow-edge-view.tsx | 24 +- packages/workflow-renderer/src/index.ts | 22 + .../src/lib/humanize-block-name.ts | 20 + .../src/workflow-block/source-handle.ts | 44 + .../src/workflow-block/sub-block-row-view.tsx | 86 +- .../workflow-block/workflow-block-border.tsx | 1640 +++++++++++++++++ .../workflow-block/workflow-block-view.tsx | 937 ++++++++-- packages/workflow-types/src/workflow.ts | 90 + 53 files changed, 4597 insertions(+), 620 deletions(-) create mode 100644 apps/sim/lib/workflows/blocks/workflow-block-border-mount.test.tsx create mode 100644 packages/workflow-renderer/src/lib/humanize-block-name.ts create mode 100644 packages/workflow-renderer/src/workflow-block/source-handle.ts create mode 100644 packages/workflow-renderer/src/workflow-block/workflow-block-border.tsx diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index 697ba4099c0..0086975edad 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -734,6 +734,33 @@ async function handleBlockOperationTx( break } + case BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED: { + if (!payload.id || payload.errorEnabled === undefined) { + throw new Error('Missing required fields for update error enabled operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + data: sql`jsonb_set( + coalesce(${workflowBlocks.data}, '{}'::jsonb), + '{errorEnabled}', + ${JSON.stringify(payload.errorEnabled)}::jsonb, + true + )`, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug(`Updated block error output: ${payload.id} -> ${payload.errorEnabled}`) + break + } + case BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE: { if (!payload.id || !payload.canonicalId || !payload.canonicalMode) { throw new Error('Missing required fields for update canonical mode operation') diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index 4f4a4296c16..88679cb5622 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -27,6 +27,7 @@ const WRITE_OPERATIONS: string[] = [ BLOCK_OPERATIONS.TOGGLE_ENABLED, BLOCK_OPERATIONS.UPDATE_PARENT, BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, + BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED, BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE, BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES, BLOCK_OPERATIONS.TOGGLE_HANDLES, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx index 08bde247dbc..97307ce5c4b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx @@ -1,6 +1,6 @@ import { memo, useCallback } from 'react' import { Button, cn, Duplicate, PlayOutline, Tooltip, Trash2, toast } from '@sim/emcn' -import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react' +import { Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react' import { useShallow } from 'zustand/react/shallow' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -14,14 +14,16 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store' const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 } const ACTION_BUTTON_STYLES = [ - 'size-[23px] rounded-lg p-0', - 'border border-[var(--border)] bg-[var(--surface-5)]', - 'text-[var(--text-secondary)]', - 'hover-hover:border-transparent hover-hover:bg-[var(--brand-secondary)] hover-hover:!text-[var(--text-inverse)]', - 'dark:border-transparent dark:bg-[var(--surface-7)] dark:hover-hover:bg-[var(--brand-secondary)]', + 'size-[24px] rounded-md p-0', + 'border-none bg-transparent text-[var(--text-icon)]', + 'hover-hover:bg-[var(--surface-5)] hover-hover:!text-[var(--text-primary)]', + 'dark:hover-hover:bg-[var(--surface-4)]', + 'transition-[background-color,color,opacity,transform] duration-150 active:scale-[0.96]', ].join(' ') -const ICON_SIZE = 'size-[11px]' +const ICON_SIZE = 'size-[14px]' + +type ActionId = 'run' | 'enabled' | 'lock' | 'duplicate' | 'remove' | 'delete' /** * Props for the ActionBar component @@ -33,6 +35,8 @@ interface ActionBarProps { blockType: string /** Whether the action bar is disabled */ disabled?: boolean + /** Places the actions inside the workflow card's border swell. */ + variant?: 'floating' | 'swell' } /** @@ -42,12 +46,16 @@ interface ActionBarProps { * @component */ export const ActionBar = memo( - function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) { + function ActionBar({ + blockId, + blockType, + disabled = false, + variant = 'floating', + }: ActionBarProps) { const { collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, collaborativeBatchToggleBlockEnabled, - collaborativeBatchToggleBlockHandles, collaborativeBatchToggleLocked, } = useCollaborativeWorkflow() const { setPendingSelection } = useWorkflowRegistry() @@ -78,30 +86,22 @@ export const ActionBar = memo( ) }, [blockId, collaborativeBatchAddBlocks, setPendingSelection]) - const { - isEnabled, - horizontalHandles, - parentId, - parentType, - isLocked, - isParentLocked, - isParentDisabled, - } = useWorkflowStore( - useShallow((state) => { - const block = state.blocks[blockId] - const parentId = block?.data?.parentId - const parentBlock = parentId ? state.blocks[parentId] : undefined - return { - isEnabled: block?.enabled ?? true, - horizontalHandles: block?.horizontalHandles ?? false, - parentId, - parentType: parentBlock?.type, - isLocked: block?.locked ?? false, - isParentLocked: parentBlock?.locked ?? false, - isParentDisabled: parentBlock ? !parentBlock.enabled : false, - } - }) - ) + const { isEnabled, parentId, parentType, isLocked, isParentLocked, isParentDisabled } = + useWorkflowStore( + useShallow((state) => { + const block = state.blocks[blockId] + const parentId = block?.data?.parentId + const parentBlock = parentId ? state.blocks[parentId] : undefined + return { + isEnabled: block?.enabled ?? true, + parentId, + parentType: parentBlock?.type, + isLocked: block?.locked ?? false, + isParentLocked: parentBlock?.locked ?? false, + isParentDisabled: parentBlock ? !parentBlock.enabled : false, + } + }) + ) const { activeWorkflowId } = useWorkflowRegistry() const isExecuting = useIsCurrentWorkflowExecuting() @@ -112,7 +112,6 @@ export const ActionBar = memo( const isStartBlock = isInputDefinitionTrigger(blockType) const isResponseBlock = blockType === 'response' const isNoteBlock = blockType === 'note' - const isSubflowBlock = blockType === 'loop' || blockType === 'parallel' const isInsideSubflow = parentId && (parentType === 'loop' || parentType === 'parallel') const snapshot = activeWorkflowId ? getLastExecutionSnapshot(activeWorkflowId) : null @@ -132,6 +131,61 @@ export const ActionBar = memo( isTriggerBlock || (snapshot && incomingEdges.every((edge) => isSourceSatisfied(edge.source))) const canRunFromBlock = dependenciesSatisfied && !isNoteBlock && !isInsideSubflow && !isExecuting + const isSwell = variant === 'swell' + const firstActionId: ActionId = + !isNoteBlock && !isInsideSubflow + ? 'run' + : !isNoteBlock + ? 'enabled' + : userPermissions.canAdmin + ? 'lock' + : !isStartBlock && !isResponseBlock + ? 'duplicate' + : 'delete' + /* + * Icon treatment follows the swell's own fill, published by the card view + * as `data-node-selected`. Keying off React Flow's raw `selected` would + * diverge: an executing block keeps the success ring, so the swell stays + * gray (or retracts) while `selected` is still true. + */ + const actionButtonStyles = cn( + ACTION_BUTTON_STYLES, + isSwell && [ + 'group-data-[node-selected]:text-[var(--surface-2)]', + 'hover-hover:group-data-[node-selected]:bg-[var(--surface-2)]', + 'hover-hover:group-data-[node-selected]:!text-[var(--text-primary)]', + ] + ) + /* + * End-button silhouettes: a straight diagonal edge running parallel to + * the gray swell's taper above it (slope 20/24 ≈ 40° from vertical — the + * falloff curve's central gradient), blended into the top edge with a + * generous r8 arc and meeting the bottom edge with a pointier r2.5 arc; + * the outer corners keep a modest r4. The right (delete) shape is the + * exact mirror of the left (first action) shape. Width is 40px — outer + * diagonal pushed out so the glyph has room before the cut; inner side + * stays tight against neighboring actions. The row is right-[24px] to + * match the swell anchor inset (right-aligned on the card). Glyphs shift + * away from the outer cut (+6 / -6). Play gets an extra +2px because the + * triangle’s optical center sits left of its viewBox center. + */ + const getActionButtonStyles = (actionId: ActionId) => + cn( + actionButtonStyles, + isSwell && + actionId === firstActionId && + "!w-[40px] [clip-path:path('M23.75_0A8_8_0_0_0_17.6_2.88L3.41_19.9A2.5_2.5_0_0_0_5.34_24L36_24A4_4_0_0_0_40_20L40_4A4_4_0_0_0_36_0Z')] [&_svg]:translate-y-px", + isSwell && + actionId === firstActionId && + (actionId === 'run' ? '[&_svg]:translate-x-[8px]' : '[&_svg]:translate-x-[6px]'), + isSwell && + actionId === 'delete' && + "!w-[40px] [clip-path:path('M16.25_0A8_8_0_0_1_22.4_2.88L36.59_19.9A2.5_2.5_0_0_1_34.66_24L4_24A4_4_0_0_1_0_20L0_4A4_4_0_0_1_4_0Z')] [&_svg]:-translate-x-[6px] [&_svg]:translate-y-px", + /* `!` is required: these buttons are also `disabled` when locked, and + the emcn Button base carries `disabled:opacity-70`, which outranks a + plain `opacity-35` on specificity. */ + actionId !== 'lock' && isLocked && '!opacity-35' + ) const handleRunFromBlockClick = useCallback(() => { if (!activeWorkflowId || !canRunFromBlock) return @@ -153,205 +207,210 @@ export const ActionBar = memo( return (
- {!isNoteBlock && !isInsideSubflow && ( - - - - - - - - {(() => { - if (disabled) return getTooltipMessage('Run from block') - if (isExecuting) return 'Running...' - if (!dependenciesSatisfied) return 'Run previous blocks first' - return 'Run from block' - })()} - - - )} +
+
+ {!isNoteBlock && !isInsideSubflow && ( + + + + + + + + {(() => { + if (isLocked || isParentLocked) return 'Block is locked' + if (disabled) return getTooltipMessage('Run from block') + if (isExecuting) return 'Running...' + if (!dependenciesSatisfied) return 'Run previous blocks first' + return 'Run from block' + })()} + + + )} - {!isNoteBlock && ( - - - - - - {isLocked || isParentLocked - ? 'Block is locked' - : !isEnabled && isParentDisabled - ? 'Parent container is disabled' - : getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')} - - - )} + {!isNoteBlock && ( + + + + + + + + {isLocked || isParentLocked + ? 'Block is locked' + : !isEnabled && isParentDisabled + ? 'Parent container is disabled' + : getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')} + + + )} - {userPermissions.canAdmin && ( - - - - - - {isLocked && isParentLocked - ? 'Parent container is locked' - : isLocked - ? 'Unlock Block' - : 'Lock Block'} - - - )} + {userPermissions.canAdmin && ( + + + + + + + + {isLocked && isParentLocked + ? 'Parent container is locked' + : isLocked + ? 'Unlock Block' + : 'Lock Block'} + + + )} - {!isStartBlock && !isResponseBlock && ( - - - - - - {isLocked || isParentLocked - ? 'Block is locked' - : getTooltipMessage('Duplicate Block')} - - - )} + {!isStartBlock && !isResponseBlock && ( + + + + + + + + {isLocked || isParentLocked + ? 'Block is locked' + : getTooltipMessage('Duplicate Block')} + + + )} - {!isNoteBlock && !isSubflowBlock && ( - - - - - - {isLocked || isParentLocked - ? 'Block is locked' - : getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')} - - - )} - - {!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && ( - - - - - - {isLocked || isParentLocked - ? 'Block is locked' - : getTooltipMessage('Remove from Subflow')} - - - )} + {!isStartBlock && parentId && (parentType === 'loop' || parentType === 'parallel') && ( + + + + + + + + {isLocked || isParentLocked + ? 'Block is locked' + : getTooltipMessage('Remove from Subflow')} + + + )} - - - - - - {isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')} - - + + + + + + + + {isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')} + + +
+
) }, @@ -367,7 +426,8 @@ export const ActionBar = memo( return ( prevProps.blockId === nextProps.blockId && prevProps.blockType === nextProps.blockType && - prevProps.disabled === nextProps.disabled + prevProps.disabled === nextProps.disabled && + prevProps.variant === nextProps.variant ) } ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx index acc8e422da7..c92301ffe9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu/block-menu.tsx @@ -11,7 +11,6 @@ export interface BlockInfo { id: string type: string enabled: boolean - horizontalHandles: boolean parentId?: string parentType?: string locked?: boolean @@ -34,7 +33,6 @@ export interface BlockMenuProps { onDuplicate: () => void onDelete: () => void onToggleEnabled: () => void - onToggleHandles: () => void onRemoveFromSubflow: () => void onOpenEditor: () => void onRename: () => void @@ -74,7 +72,6 @@ export function BlockMenu({ onDuplicate, onDelete, onToggleEnabled, - onToggleHandles, onRemoveFromSubflow, onOpenEditor, onRename, @@ -207,17 +204,6 @@ export function BlockMenu({ {hasBlockWithDisabledParent ? 'Parent is disabled' : getToggleEnabledLabel()} )} - {!allNoteBlocks && !isSubflow && ( - { - onToggleHandles() - onClose() - }} - > - Flip Handles - - )} {canRemoveFromSubflow && ( blockState?: any + /** Persists and broadcasts the Error output toggle. */ + onSetErrorOutputEnabled?: (blockId: string, enabled: boolean) => void + /** Persists and broadcasts edge removals caused by disabling Error output. */ + onRemoveEdges?: (edgeIds: string[]) => void } /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts index 1d41ca55463..3030c36ec0e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts @@ -40,6 +40,8 @@ export function shouldSkipBlockRender( prevProps.data.config === nextProps.data.config && prevProps.data.subBlockValues === nextProps.data.subBlockValues && prevProps.data.blockState === nextProps.data.blockState && + prevProps.data.onSetErrorOutputEnabled === nextProps.data.onSetErrorOutputEnabled && + prevProps.data.onRemoveEdges === nextProps.data.onRemoveEdges && prevProps.selected === nextProps.selected && prevProps.dragging === nextProps.dragging ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 4794bd82128..056f54cfbef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -1,10 +1,28 @@ -import { memo, useCallback, useEffect, useMemo, useRef } from 'react' +import { type ComponentType, Fragment, memo, useCallback, useEffect, useMemo, useRef } from 'react' import { createLogger } from '@sim/logger' import { SubBlockRowView, WorkflowBlockView } from '@sim/workflow-renderer' +import { isPositionedSourceHandle, isPositionedTargetHandle } from '@sim/workflow-types/workflow' import { isEqual } from 'es-toolkit' +import { + ArrowLeftRight, + ArrowUpDown, + Braces, + Clock, + Globe, + Hash, + KeyRound, + ListFilter, + MessageSquareText, + Paperclip, + SkipForward, + SlidersHorizontal, + Sparkles, + ToggleLeft, + Wrench, +} from 'lucide-react' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' -import { type NodeProps, useUpdateNodeInternals } from 'reactflow' +import { type NodeProps, useStore as useReactFlowStore, useUpdateNodeInternals } from 'reactflow' import { useStoreWithEqualityFn } from 'zustand/traditional' import { getBaseUrl } from '@/lib/core/utils/urls' import { createMcpToolId } from '@/lib/mcp/shared' @@ -15,6 +33,7 @@ import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/determi import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology' import { getDisplayValue, + hasDisplayableRowValue, resolveDropdownLabel, resolveFilterFieldLabel, resolveSkillsLabel, @@ -67,6 +86,7 @@ import { useWorkflowMap } from '@/hooks/queries/workflows' import { useReactiveConditions } from '@/hooks/use-reactive-conditions' import { useSelectorDisplayName } from '@/hooks/use-selector-display-name' import { getModelSunsetStatus } from '@/providers/models' +import { usePanelEditorStore, usePanelStore } from '@/stores/panel' import { useVariablesStore } from '@/stores/variables/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -81,6 +101,245 @@ const EMPTY_SUBBLOCK_VALUES = {} as Record /** Stable empty map for rows that never resolve MCP tool names */ const EMPTY_MCP_TOOL_NAMES: ReadonlyMap = new Map() +/** + * Selector subblock types whose hydrated value names the block's primary + * target (table, channel, knowledge base, …) — promoted to a chip. + */ +const CHIP_TARGET_SELECTOR_TYPES = new Set([ + 'table-selector', + 'knowledge-base-selector', + 'workflow-selector', + 'mcp-server-selector', + 'mcp-tool-selector', + 'channel-selector', + 'user-selector', + 'file-selector', + 'sheet-selector', + 'folder-selector', + 'project-selector', + 'document-selector', +]) + +/** Maximum fragments in the statement line; remaining candidates fall back to rows. */ +const MAX_CHIPS = 2 + +type MetaIcon = ComponentType<{ className?: string }> + +/** Leading icons for compact meta rows, keyed by subblock id. */ +const SUBBLOCK_META_ICONS_BY_ID: Record = { + filterBuilder: ListFilter, + bulkFilterBuilder: ListFilter, + filter: ListFilter, + filterCriteria: ListFilter, + sortBuilder: ArrowUpDown, + sort: ArrowUpDown, + limit: Hash, + offset: SkipForward, + rowId: KeyRound, + url: Globe, + method: ArrowLeftRight, + body: Braces, + data: Braces, +} + +/** Leading icons for compact meta rows, keyed by subblock type. */ +const SUBBLOCK_META_ICONS_BY_TYPE: Record = { + code: Braces, + 'messages-input': MessageSquareText, + 'tool-input': Wrench, + 'skill-input': Sparkles, + 'oauth-input': KeyRound, + switch: ToggleLeft, + 'file-upload': Paperclip, + 'time-input': Clock, + slider: SlidersHorizontal, +} + +/** Resolves the meta-row icon for a subblock; null keeps the labeled row. */ +function getMetaIcon(subBlock: SubBlockConfig): MetaIcon | null { + return ( + SUBBLOCK_META_ICONS_BY_ID[subBlock.id] ?? SUBBLOCK_META_ICONS_BY_TYPE[subBlock.type] ?? null + ) +} + +/** A value token in a summary sentence, referencing a visible subblock. */ +interface SentenceToken { + id: string +} + +type SentenceSegment = string | SentenceToken + +const T = (id: string): SentenceToken => ({ id }) + +/** + * Builds the natural-language summary for a block as text fragments + * interleaved with value tokens (rendered as inline chips). Returns null for + * block types or states without a template - those keep the field-row layout. + * `resolve` returns the first listed subblock id that is visible with a + * displayable value, so templates only reference real, configured fields. + */ +function buildSentenceSegments( + type: string, + operation: unknown, + resolve: (...ids: string[]) => string | null +): SentenceSegment[] | null { + if (type === 'table') { + const table = resolve('tableSelector', 'manualTableId') + if (!table) return null + const rowId = resolve('rowId') + const data = resolve('data') + const filter = resolve('filterBuilder', 'bulkFilterBuilder', 'filter') + const sort = resolve('sortBuilder', 'sort') + const limit = resolve('limit') + + switch (operation) { + case 'query_rows': { + const segments: SentenceSegment[] = ['Queries rows from', T(table)] + if (filter) segments.push(', where', T(filter)) + if (sort) segments.push(', sorted by', T(sort)) + if (limit) segments.push(', up to', T(limit), 'rows') + return segments + } + case 'insert_row': + return ['Inserts a row into', T(table), ...(data ? [', with', T(data)] : [])] + case 'upsert_row': { + const conflict = resolve('conflictColumnSelector', 'manualConflictColumn') + return ['Upserts a row into', T(table), ...(conflict ? [', keyed on', T(conflict)] : [])] + } + case 'batch_insert_rows': { + const rows = resolve('rows') + return ['Inserts', ...(rows ? [T(rows)] : ['rows']), 'into', T(table)] + } + case 'update_row': + return [ + 'Updates row', + ...(rowId ? [T(rowId)] : []), + 'in', + T(table), + ...(data ? [', setting', T(data)] : []), + ] + case 'delete_row': + return ['Deletes row', ...(rowId ? [T(rowId)] : []), 'from', T(table)] + case 'get_row': + return ['Fetches row', ...(rowId ? [T(rowId)] : []), 'from', T(table)] + case 'update_rows_by_filter': + return [ + 'Updates rows in', + T(table), + ...(filter ? [', where', T(filter)] : []), + ...(data ? [', setting', T(data)] : []), + ] + case 'delete_rows_by_filter': + return ['Deletes rows from', T(table), ...(filter ? [', where', T(filter)] : [])] + case 'get_schema': + return ['Reads the schema of', T(table)] + default: + return null + } + } + + if (type === 'agent') { + const model = resolve('model') + if (!model) return null + const messages = resolve('messages') + const tools = resolve('tools') + const segments: SentenceSegment[] = ['Prompts', T(model)] + if (messages) segments.push('with', T(messages)) + if (tools) segments.push(', using', T(tools)) + return segments + } + + if (type === 'api') { + const url = resolve('url') + if (!url) return null + const method = resolve('method') + const body = resolve('body') + const segments: SentenceSegment[] = method + ? ['Sends a', T(method), 'request to', T(url)] + : ['Sends a request to', T(url)] + if (body) segments.push(', with body', T(body)) + return segments + } + + if (type === 'function') { + const code = resolve('code') + if (!code) return null + return ['Runs code', T(code)] + } + + return null +} + +/** Approximate character widths for the sentence line estimate (px). */ +const SENTENCE_TEXT_CHAR_PX = 6.3 +const SENTENCE_CHIP_CHAR_PX = 6.8 +/** Inline chip horizontal padding + surrounding gap (px). */ +const SENTENCE_CHIP_EXTRA_PX = 26 +/** Usable sentence width inside the card (px). */ +const SENTENCE_WRAP_WIDTH_PX = 224 +/** Chip text is truncated around this many characters by max-width. */ +const SENTENCE_CHIP_MAX_CHARS = 24 +/** + * Rendered cap on an inline value chip: `max-w-[160px]` on the chip itself + * (see SubBlockRowView's `inline-value`). Without this clamp a long value is + * estimated wider than it can ever paint, which predicts an extra wrapped + * line and pads the card's height. + */ +const SENTENCE_CHIP_MAX_PX = 160 + +/** + * Estimates the wrapped line count of a summary sentence for the + * deterministic node height. Approximate by design - being off by a line + * only affects node bounds, never handle anchoring (source/target anchor to + * the header and the error port anchors to the bottom). + */ +function estimateSentenceLines( + segments: SentenceSegment[], + getValueText: (id: string) => string +): number { + let widthPx = 0 + for (const segment of segments) { + if (typeof segment === 'string') { + widthPx += segment.length * SENTENCE_TEXT_CHAR_PX + 4 + } else { + widthPx += Math.min( + Math.min(getValueText(segment.id).length, SENTENCE_CHIP_MAX_CHARS) * SENTENCE_CHIP_CHAR_PX + + SENTENCE_CHIP_EXTRA_PX, + SENTENCE_CHIP_MAX_PX + ) + } + } + return Math.max(1, Math.ceil(widthPx / SENTENCE_WRAP_WIDTH_PX)) +} + +/** + * Priority for promoting a visible subblock into the chips row: the + * operation first, then the primary target selector, then the model. + * Returns null for subblocks that stay as label/value rows. + */ +function chipPriority(subBlock: SubBlockConfig): number | null { + if (subBlock.id === 'operation') return 0 + if (CHIP_TARGET_SELECTOR_TYPES.has(subBlock.type)) return 1 + if (subBlock.id === 'model') return 2 + return null +} + +/** + * Names of MCP tool-schema parameters whose argument values are displayable + * on the collapsed node. Params without a set value are hidden from the + * preview, matching the empty-row filtering applied to regular subblocks. + */ +function getDisplayableMcpParamNames(schemaValue: unknown, argsValue: unknown): string[] { + const schema = schemaValue as { properties?: Record } | undefined + const properties = schema?.properties + if (!properties || typeof properties !== 'object') return [] + const args = (argsValue && typeof argsValue === 'object' ? argsValue : {}) as Record< + string, + unknown + > + return Object.keys(properties).filter((name) => getDisplayValue(args[name]) !== '-') +} + interface BlockSunset { status: 'legacy' | 'deprecated' kind: 'block' | 'model' @@ -167,6 +426,10 @@ interface SubBlockRowProps { displayAdvancedOptions?: boolean canonicalIndex?: ReturnType canonicalModeOverrides?: Record + /** Presentation variant forwarded to the row view. */ + variant?: 'row' | 'meta' | 'statement-primary' | 'statement-muted' | 'inline-value' + /** Leading icon forwarded to the row view (meta variant). */ + icon?: MetaIcon } /** @@ -192,7 +455,9 @@ const areSubBlockRowPropsEqual = ( valueEqual && prevProps.displayAdvancedOptions === nextProps.displayAdvancedOptions && prevProps.canonicalIndex === nextProps.canonicalIndex && - prevProps.canonicalModeOverrides === nextProps.canonicalModeOverrides + prevProps.canonicalModeOverrides === nextProps.canonicalModeOverrides && + prevProps.variant === nextProps.variant && + prevProps.icon === nextProps.icon ) } @@ -213,6 +478,8 @@ const SubBlockRow = memo(function SubBlockRow({ displayAdvancedOptions, canonicalIndex, canonicalModeOverrides, + variant, + icon, }: SubBlockRowProps) { const getStringValue = useCallback( (key?: string): string | undefined => { @@ -468,7 +735,13 @@ const SubBlockRow = memo(function SubBlockRow({ const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value) return ( - + ) }, areSubBlockRowPropsEqual) @@ -563,6 +836,112 @@ export const WorkflowBlock = memo(function WorkflowBlock({ isEqual ) + /** + * Whether a persisted legacy error route is wired from this block. The + * renderer uses this only to retain a non-interactive edge anchor. + */ + const hasErrorConnection = useWorkflowStore( + useCallback( + (state) => state.edges.some((edge) => edge.source === id && edge.sourceHandle === 'error'), + [id] + ) + ) + + /** + * Handle ids whose connected edge is highlighted because an endpoint block + * is selected — the view darkens those tabs to the edge highlight color so + * port and line read as one piece. Serialized to a string so the ReactFlow + * store subscription only re-renders on real changes. + */ + const editorBlockId = usePanelEditorStore((state) => state.currentBlockId) + const panelActiveTab = usePanelStore((state) => state.activeTab) + const editorOpenBlockId = panelActiveTab === 'editor' ? editorBlockId : null + const highlightedHandleKey = useReactFlowStore( + useCallback( + (state) => { + const keys: string[] = [] + for (const edge of state.edges) { + if (edge.source !== id && edge.target !== id) continue + /* + * Must mirror workflow-edge's shouldHighlightEdge exactly: the edge + * darkens when an endpoint is canvas-selected OR open in the editor + * panel. If the knob checks fewer conditions than the line, a dark + * line runs into a light knob. + */ + const isHighlighted = + state.nodeInternals.get(edge.source)?.selected || + state.nodeInternals.get(edge.target)?.selected || + (edge.data as { isConnectedToSelection?: boolean } | undefined) + ?.isConnectedToSelection || + (editorOpenBlockId !== null && + (edge.source === editorOpenBlockId || edge.target === editorOpenBlockId)) + if (!isHighlighted) continue + if (edge.source === id) keys.push(edge.sourceHandle || 'source') + if (edge.target === id) keys.push(edge.targetHandle || 'target') + } + return keys.sort().join('|') + }, + [id, editorOpenBlockId] + ) + ) + const highlightedHandles = useMemo( + () => new Set(highlightedHandleKey ? highlightedHandleKey.split('|') : []), + [highlightedHandleKey] + ) + const connectedSourceHandleKey = useReactFlowStore( + useCallback( + (state) => { + const handles = new Set() + for (const edge of state.edges) { + if (edge.source === id && isPositionedSourceHandle(edge.sourceHandle)) { + handles.add(edge.sourceHandle) + } + } + return Array.from(handles).sort().join('|') + }, + [id] + ) + ) + const connectedSourceHandles = useMemo( + () => new Set(connectedSourceHandleKey ? connectedSourceHandleKey.split('|') : []), + [connectedSourceHandleKey] + ) + const connectedTargetHandleKey = useReactFlowStore( + useCallback( + (state) => { + const handles = new Set() + for (const edge of state.edges) { + if (edge.target === id && isPositionedTargetHandle(edge.targetHandle)) { + handles.add(edge.targetHandle) + } + } + return Array.from(handles).sort().join('|') + }, + [id] + ) + ) + const connectedTargetHandles = useMemo( + () => new Set(connectedTargetHandleKey ? connectedTargetHandleKey.split('|') : []), + [connectedTargetHandleKey] + ) + + const errorOutputEnabled = Boolean(currentBlock?.errorEnabled || hasErrorConnection) + const handleToggleErrorOutput = useCallback( + (next: boolean) => { + const store = useWorkflowStore.getState() + data.onSetErrorOutputEnabled?.(id, next) + if (!next) { + /* Turning the branch off removes its connections — a hidden error + edge would still reroute failures with no visible affordance. */ + const errorEdgeIds = store.edges + .filter((edge) => edge.source === id && edge.sourceHandle === 'error') + .map((edge) => edge.id) + if (errorEdgeIds.length > 0) data.onRemoveEdges?.(errorEdgeIds) + } + }, + [data.onRemoveEdges, data.onSetErrorOutputEnabled, id] + ) + const posthog = usePostHog() const sunset = getBlockSunset(config, name, blockSubBlockValues.model, currentWorkflow.isDiffMode) @@ -658,12 +1037,21 @@ export const WorkflowBlock = memo(function WorkflowBlock({ return false } - if (!block.condition) return true + if (block.condition && !evaluateSubBlockCondition(block.condition, rawValues)) { + return false + } - return evaluateSubBlockCondition(block.condition, rawValues) + return hasDisplayableRowValue(block, rawValues[block.id]) }) - visibleSubBlocks.forEach((block) => { + const chipBlocks = visibleSubBlocks + .filter((block) => chipPriority(block) !== null) + .sort((a, b) => (chipPriority(a) ?? 0) - (chipPriority(b) ?? 0)) + .slice(0, MAX_CHIPS) + const chipIds = new Set(chipBlocks.map((block) => block.id)) + const rowSubBlocks = visibleSubBlocks.filter((block) => !chipIds.has(block.id)) + + rowSubBlocks.forEach((block) => { if (currentRowWidth + blockWidth > 1) { if (currentRow.length > 0) { rows.push([...currentRow]) @@ -680,7 +1068,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ rows.push(currentRow) } - return { rows, stateToUse } + return { rows, stateToUse, chipBlocks } }, [ config.subBlocks, config.category, @@ -702,6 +1090,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ const subBlockRows = subBlockRowsData.rows const subBlockState = subBlockRowsData.stateToUse + const chipBlocks = subBlockRowsData.chipBlocks const topologySubBlocks = data.isPreview ? (data.blockState?.subBlocks ?? {}) : (currentStoreBlock?.subBlocks ?? {}) @@ -718,13 +1107,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({ : displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex) }, [subBlockState, displayAdvancedMode, config.subBlocks, canonicalIndex, canEditWorkflow]) - /** - * Determine if block has content below the header (subblocks or error row). - * Controls header border visibility and content container rendering. - */ const shouldShowDefaultHandles = config.category !== 'triggers' && type !== 'starter' && !displayTriggerMode - const hasContentBelowHeader = subBlockRows.length > 0 || shouldShowDefaultHandles /** * Compute per-condition rows (title/value/id) for condition blocks so we can render @@ -751,6 +1135,18 @@ export const WorkflowBlock = memo(function WorkflowBlock({ })) }, [type, topologySubBlocks, id]) + /** + * Whether anything renders below the header — subblock rows, chips, or the + * condition/router branch rows. + */ + const showsErrorRow = shouldShowDefaultHandles && type !== 'response' + const hasContentBelowHeader = + subBlockRows.length > 0 || + chipBlocks.length > 0 || + conditionRows.length > 0 || + routerRows.length > 0 || + showsErrorRow + /** * Total rendered row count. `mcp-dynamic-args` expands one row per parameter * in the cached tool schema, so we count those properties instead of 1. @@ -760,11 +1156,10 @@ export const WorkflowBlock = memo(function WorkflowBlock({ for (const row of subBlockRows) { for (const subBlock of row) { if (subBlock.type === 'mcp-dynamic-args') { - const schema = subBlockState._toolSchema?.value as - | { properties?: Record } - | undefined - const properties = schema?.properties - count += properties && typeof properties === 'object' ? Object.keys(properties).length : 0 + count += getDisplayableMcpParamNames( + subBlockState._toolSchema?.value, + subBlockState[subBlock.id]?.value + ).length } else { count += 1 } @@ -773,6 +1168,27 @@ export const WorkflowBlock = memo(function WorkflowBlock({ return count }, [subBlockRows, subBlockState]) + /** + * Natural-language summary data: segments + line estimate for the block + * types with a sentence template. Null keeps the field-row layout. + */ + const sentenceData = useMemo(() => { + if (type === 'condition' || type === 'router_v2') return null + const visibleSubBlocksById = new Map() + for (const subBlock of chipBlocks) visibleSubBlocksById.set(subBlock.id, subBlock) + for (const row of subBlockRows) { + for (const subBlock of row) visibleSubBlocksById.set(subBlock.id, subBlock) + } + const resolve = (...ids: string[]) => + ids.find((candidate) => visibleSubBlocksById.has(candidate)) ?? null + const segments = buildSentenceSegments(type, subBlockState.operation?.value, resolve) + if (!segments) return null + const lines = estimateSentenceLines(segments, (subBlockId) => + getDisplayValue(subBlockState[subBlockId]?.value) + ) + return { segments, visibleSubBlocksById, lines } + }, [type, chipBlocks, subBlockRows, subBlockState]) + /** * Compute and publish deterministic layout metrics for workflow blocks. * This avoids ResizeObserver/animation-frame jitter and prevents initial "jump". @@ -784,9 +1200,12 @@ export const WorkflowBlock = memo(function WorkflowBlock({ blockType: type, category: config.category, displayTriggerMode, - visibleSubBlockCount: totalRenderedRowCount, + visibleSubBlockCount: sentenceData ? 0 : totalRenderedRowCount, conditionRowCount: conditionRows.length, routerRowCount: routerRows.length, + chipCount: sentenceData ? 0 : chipBlocks.length, + sentenceLineCount: sentenceData?.lines ?? 0, + hasErrorRow: showsErrorRow, }) }, dependencies: [ @@ -796,7 +1215,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({ totalRenderedRowCount, conditionRows.length, routerRows.length, + chipBlocks.length, + sentenceData?.lines ?? 0, + Boolean(sentenceData), horizontalHandles, + showsErrorRow, ], }) @@ -820,6 +1243,66 @@ export const WorkflowBlock = memo(function WorkflowBlock({ const webhookProviderName = webhookProvider ? getProviderName(webhookProvider) : undefined + const isBranchBlock = type === 'condition' || type === 'router_v2' + + const sentence = sentenceData ? ( + <> + {sentenceData.segments.map((segment, index) => { + if (typeof segment === 'string') { + const glue = index > 0 && !segment.startsWith(',') && !segment.startsWith('.') ? ' ' : '' + return {`${glue}${segment}`} + } + const subBlock = sentenceData.visibleSubBlocksById.get(segment.id) + if (!subBlock) return null + const rawValue = subBlockState[segment.id]?.value + return ( + + {' '} + + + ) + })} + + ) : undefined + + const chips = + isBranchBlock || sentenceData || chipBlocks.length === 0 ? undefined : ( + <> + {chipBlocks.map((subBlock, index) => ( + + {index > 0 && ·} + + + ))} + + ) + const rows = type === 'condition' || type === 'router_v2' ? null : ( <> @@ -827,25 +1310,21 @@ export const WorkflowBlock = memo(function WorkflowBlock({ row.flatMap((subBlock) => { const rawValue = subBlockState[subBlock.id]?.value if (subBlock.type === 'mcp-dynamic-args') { - const schema = subBlockState._toolSchema?.value as - | { properties?: Record } - | undefined - const properties = schema?.properties - if (properties && typeof properties === 'object') { - const args = (rawValue && typeof rawValue === 'object' ? rawValue : {}) as Record< - string, - unknown - > - return Object.keys(properties).map((paramName) => ( + const args = (rawValue && typeof rawValue === 'object' ? rawValue : {}) as Record< + string, + unknown + > + return getDisplayableMcpParamNames(subBlockState._toolSchema?.value, rawValue).map( + (paramName) => ( - )) - } - return [] + ) + ) } + const metaIcon = getMetaIcon(subBlock) return [ , ] }) @@ -882,6 +1363,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ iconBgColor={config.bgColor} horizontalHandles={horizontalHandles} shouldShowDefaultHandles={shouldShowDefaultHandles} + blockHeight={blockHeight} hasContentBelowHeader={hasContentBelowHeader} conditionRows={conditionRows} routerRows={routerRows} @@ -925,10 +1407,21 @@ export const WorkflowBlock = memo(function WorkflowBlock({ contentRef={contentRef} actionBar={ !data.isPreview && !data.isEmbedded ? ( - + ) : undefined } rows={rows} + chips={chips} + typeLabel={config.name} + sentence={sentence} + hasErrorConnection={hasErrorConnection} + errorOutputEnabled={errorOutputEnabled} + onToggleErrorOutput={ + canEditWorkflow && data.onSetErrorOutputEnabled ? handleToggleErrorOutput : undefined + } + highlightedHandles={highlightedHandles} + connectedSourceHandles={connectedSourceHandles} + connectedTargetHandles={connectedTargetHandles} /> ) }, shouldSkipBlockRender) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx index eff2ef11c30..225f6cc0b34 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge.tsx @@ -1,8 +1,9 @@ -import { memo, useMemo } from 'react' +import { memo, useCallback, useMemo } from 'react' import { type EdgeDiffStatus, WorkflowEdgeView } from '@sim/workflow-renderer' -import type { EdgeProps } from 'reactflow' +import { type EdgeProps, useStore } from 'reactflow' import { useShallow } from 'zustand/react/shallow' import { useLastRunEdges } from '@/stores/execution' +import { usePanelEditorStore, usePanelStore } from '@/stores/panel' import { useWorkflowDiffStore } from '@/stores/workflow-diff' /** Extended edge props with optional handle identifiers */ @@ -28,6 +29,31 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => { })) ) const lastRunEdges = useLastRunEdges() + const currentBlockId = usePanelEditorStore((state) => state.currentBlockId) + const activeTab = usePanelStore((state) => state.activeTab) + + /** + * Match the block ring: darken edges when an endpoint is canvas-selected or + * open in the editor panel (same `--text-secondary` as the selection ring). + */ + const isEndpointSelected = useStore( + useCallback( + (state) => + Boolean( + state.nodeInternals.get(source)?.selected || state.nodeInternals.get(target)?.selected + ), + [source, target] + ) + ) + const isConnectedToSelection = Boolean( + isEndpointSelected || + (data as { isConnectedToSelection?: boolean } | undefined)?.isConnectedToSelection + ) + const isConnectedToEditor = + activeTab === 'editor' && + currentBlockId !== null && + (currentBlockId === source || currentBlockId === target) + const shouldHighlightEdge = isConnectedToSelection || isConnectedToEditor const previewExecutionStatus = ( data as { executionStatus?: 'success' | 'error' | 'not-executed' } | undefined @@ -66,6 +92,7 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => { diffStatus={diffStatus} runStatus={runStatus} isPreviewRun={Boolean(previewExecutionStatus)} + isConnectedToSelection={shouldHighlightEdge} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts index 13a9968e742..90167a8bb89 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu.ts @@ -37,7 +37,6 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo id: n.id, type: block?.type || '', enabled: block?.enabled ?? true, - horizontalHandles: block?.horizontalHandles ?? false, parentId, parentType, locked: block?.locked ?? false, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts index b4996dd90ad..e1b5121afe4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.ts @@ -50,17 +50,18 @@ export function getBlockRingStyles(options: BlockRingOptions): { const ringClassName = cn( // Executing block: pulsing success ring with prominent thickness (highest priority) isExecuting && 'ring-[3.5px] ring-[var(--border-success)] animate-ring-pulse', - // Editor open, selected, or preview selection: static blue ring + // Editor open, selected, or preview selection: neutral highlight ring, + // matching the selection-highlighted edge color !isExecuting && (isEditorOpen || isSelected || isPreviewSelection) && - 'ring-[1.75px] ring-[var(--brand-secondary)]', + 'ring-[1.5px] ring-[var(--text-secondary)]', // Non-active states use standard ring utilities !isExecuting && !isEditorOpen && !isSelected && !isPreviewSelection && hasRing && - 'ring-[1.75px]', + 'ring-[1.5px]', // Pending state: warning ring !isExecuting && !isEditorOpen && !isSelected && isPending && 'ring-[var(--warning)]', // Deleted state (highest priority after active/pending) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index 782e00428f9..fb1fba5f09d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isPositionedSourceHandle } from '@sim/workflow-types/workflow' import type { TraceSpan } from '@/lib/logs/types' import type { BlockChildWorkflowStartedData, @@ -72,6 +73,10 @@ function shouldActivateEdgeClient( return output?.selectedRoute === handle.substring('router-'.length) } + if (isPositionedSourceHandle(handle)) { + return !output?.error + } + switch (handle) { case 'error': return !!output?.error diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow-constants.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow-constants.ts index f1ac35e01ee..3a64a835b36 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow-constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow-constants.ts @@ -39,8 +39,11 @@ export const defaultEdgeOptions = { type: 'custom' } as const export const reactFlowStyles = [ '[&_.react-flow__handle]:!z-[30]', '[&_.react-flow__edge-labels]:!z-[1001]', + String.raw`[&_.react-flow\_\_connectionline]:!z-[0]`, '[&_.react-flow__pane]:select-none', '[&_.react-flow__selectionpane]:select-none', + String.raw`[&_.react-flow\_\_selection]:!border-[var(--text-secondary)]`, + String.raw`[&_.react-flow\_\_selection]:!bg-[color-mix(in_oklch,var(--text-secondary)_8%,transparent)]`, '[&_.react-flow__background]:hidden', '[&_.react-flow__node-subflowNode.selected]:!shadow-none', ].join(' ') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 4f9da917a1e..27484b8e6cb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -8,6 +8,7 @@ import ReactFlow, { type Edge, type Node, type NodeChange, + type OnConnectStart, ReactFlowProvider, SelectionMode, useReactFlow, @@ -17,7 +18,20 @@ import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import type { SubflowNodeData } from '@sim/workflow-renderer' -import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer' +import { + BLOCK_DIMENSIONS, + CONTAINER_DIMENSIONS, + normalizeCursorSourceHandleId, +} from '@sim/workflow-renderer' +import { + getHorizontalWorkflowHandleSide, + getPositionedSourceHandleId, + getPositionedTargetHandleId, + isPositionedTargetHandle, + normalizePositionedSourceHandleId, + normalizePositionedTargetHandleId, + type PositionedSourceHandleSide, +} from '@sim/workflow-types/workflow' import { useShallow } from 'zustand/react/shallow' import { useSession } from '@/lib/auth/auth-client' import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/base-tool' @@ -116,6 +130,10 @@ const LazyChat = lazy(() => const logger = createLogger('Workflow') const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 } +const CONNECTION_LINE_STYLE = { + stroke: 'var(--connection-line-stroke)', + strokeWidth: 2, +} /** * Calculates the offset to paste blocks at viewport center @@ -232,7 +250,6 @@ const WorkflowContent = React.memo( const [isCanvasReady, setIsCanvasReady] = useState(false) const [potentialParentId, setPotentialParentId] = useState(null) const [selectedEdges, setSelectedEdges] = useState(new Map()) - const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false) const canvasContainerRef = useRef(null) const embeddedFitFrameRef = useRef(null) const hasCompletedInitialEmbeddedFitRef = useRef(false) @@ -526,7 +543,10 @@ const WorkflowContent = React.memo( ) /** Stores source node/handle info when a connection drag starts for drop-on-block detection. */ - const connectionSourceRef = useRef<{ nodeId: string; handleId: string } | null>(null) + const connectionSourceRef = useRef<{ + nodeId: string | null + handleId: string | null + } | null>(null) /** Tracks whether onConnect successfully handled the connection (ReactFlow pattern). */ const connectionCompletedRef = useRef(false) @@ -598,11 +618,18 @@ const WorkflowContent = React.memo( edgesToFilter = [...edges, ...reconstructedEdges] } - return edgesToFilter.filter((edge) => { - const sourceBlock = blocks[edge.source] - const targetBlock = blocks[edge.target] - return Boolean(sourceBlock && targetBlock) - }) + return edgesToFilter + .map((edge) => { + const sourceHandle = normalizePositionedSourceHandleId(edge.sourceHandle) + const targetHandle = normalizePositionedTargetHandleId(edge.targetHandle) + if (sourceHandle === edge.sourceHandle && targetHandle === edge.targetHandle) return edge + return { ...edge, sourceHandle, targetHandle } + }) + .filter((edge) => { + const sourceBlock = blocks[edge.source] + const targetBlock = blocks[edge.target] + return Boolean(sourceBlock && targetBlock) + }) }, [edges, isShowingDiff, isDiffReady, diffAnalysis, blocks]) const { userPermissions, workspacePermissions, permissionsError } = @@ -627,8 +654,8 @@ const WorkflowContent = React.memo( collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, collaborativeBatchToggleBlockEnabled, - collaborativeBatchToggleBlockHandles, collaborativeBatchToggleLocked, + collaborativeSetBlockErrorEnabled, undo, redo, } = useCollaborativeWorkflow() @@ -843,15 +870,6 @@ const WorkflowContent = React.memo( const [dragStartParentId, setDragStartParentId] = useState(null) - /** Connection line style - red for error handles, default otherwise. */ - const connectionLineStyle = useMemo( - () => ({ - stroke: isErrorConnectionDrag ? 'var(--text-error)' : 'var(--workflow-edge)', - strokeWidth: 2, - }), - [isErrorConnectionDrag] - ) - /** Logs permission loading results for debugging. */ useEffect(() => { if (permissionsError) { @@ -1216,11 +1234,6 @@ const WorkflowContent = React.memo( collaborativeBatchToggleBlockEnabled(blockIds) }, [contextMenuBlocks, collaborativeBatchToggleBlockEnabled]) - const handleContextToggleHandles = useCallback(() => { - const blockIds = contextMenuBlocks.map((block) => block.id) - collaborativeBatchToggleBlockHandles(blockIds) - }, [contextMenuBlocks, collaborativeBatchToggleBlockHandles]) - const handleContextToggleLocked = useCallback(() => { const blockIds = contextMenuBlocks.map((block) => block.id) collaborativeBatchToggleLocked(blockIds) @@ -2638,6 +2651,8 @@ const WorkflowContent = React.memo( isPending, ...(embedded && { isEmbedded: true }), isWorkflowLocked: workflowReadOnly, + onSetErrorOutputEnabled: collaborativeSetBlockErrorEnabled, + onRemoveEdges: collaborativeBatchRemoveEdges, }, // Include dynamic dimensions for container resizing calculations (must match rendered size) // Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions @@ -2659,6 +2674,8 @@ const WorkflowContent = React.memo( getBlockConfig, embedded, workflowReadOnly, + collaborativeSetBlockErrorEnabled, + collaborativeBatchRemoveEdges, ]) // Local state for nodes - allows smooth drag without store updates on every frame @@ -3085,20 +3102,36 @@ const WorkflowContent = React.memo( * Captures the source handle when a connection drag starts. * Resets connectionCompletedRef to track if onConnect handles this connection. */ - const onConnectStart = useCallback((_event: any, params: any) => { - const handleId: string | undefined = params?.handleId - setIsErrorConnectionDrag(handleId === 'error') - connectionSourceRef.current = { - nodeId: params?.nodeId, - handleId: params?.handleId, - } - connectionCompletedRef.current = false - }, []) + const onConnectStart = useCallback( + (_event, params) => { + const handleId = params.handleId ?? undefined + const isSelectedSource = Boolean( + getNodes().find((node) => node.id === params?.nodeId)?.selected + ) + const connectionLineVariant = + handleId === 'error' ? 'error' : isSelectedSource ? 'selected' : 'default' + canvasContainerRef.current?.setAttribute('data-connection-line', connectionLineVariant) + canvasContainerRef.current?.setAttribute('data-connection-active', 'true') + connectionSourceRef.current = { + nodeId: params?.nodeId, + handleId: params?.handleId, + } + connectionCompletedRef.current = false + }, + [getNodes] + ) /** Handles new edge connections with container boundary validation. */ const onConnect = useCallback( (connection: any) => { if (connection.source && connection.target) { + const normalizedConnection = { + ...connection, + sourceHandle: normalizePositionedSourceHandleId( + normalizeCursorSourceHandleId(connection.sourceHandle) + ), + targetHandle: normalizePositionedTargetHandleId(connection.targetHandle), + } // Check if connecting nodes across container boundaries const sourceNode = getNodes().find((n) => n.id === connection.source) const targetNode = getNodes().find((n) => n.id === connection.target) @@ -3106,7 +3139,7 @@ const WorkflowContent = React.memo( if (!sourceNode || !targetNode) return // Prevent connections to protected blocks (outbound from locked blocks is allowed) - if (isEdgeProtected(connection, blocks)) { + if (isEdgeProtected(normalizedConnection, blocks)) { toast({ message: 'Cannot connect to locked blocks or blocks inside locked containers', }) @@ -3116,9 +3149,9 @@ const WorkflowContent = React.memo( // Get parent information (handle container start node case) const sourceParentId = blocks[sourceNode.id]?.data?.parentId || - (connection.sourceHandle === 'loop-start-source' || - connection.sourceHandle === 'parallel-start-source' - ? connection.source + (normalizedConnection.sourceHandle === 'loop-start-source' || + normalizedConnection.sourceHandle === 'parallel-start-source' + ? normalizedConnection.source : undefined) const targetParentId = blocks[targetNode.id]?.data?.parentId @@ -3127,14 +3160,14 @@ const WorkflowContent = React.memo( // Special case for container start source: Always allow connections to nodes within the same container if ( - (connection.sourceHandle === 'loop-start-source' || - connection.sourceHandle === 'parallel-start-source') && + (normalizedConnection.sourceHandle === 'loop-start-source' || + normalizedConnection.sourceHandle === 'parallel-start-source') && blocks[targetNode.id]?.data?.parentId === sourceNode.id ) { // This is a connection from container start to a node inside the container - always allow addEdge({ - ...connection, + ...normalizedConnection, id: edgeId, type: 'workflowEdge', // Add metadata about the container context @@ -3162,7 +3195,7 @@ const WorkflowContent = React.memo( // Add appropriate metadata for container context addEdge({ - ...connection, + ...normalizedConnection, id: edgeId, type: 'workflowEdge', data: isInsideContainer @@ -3187,7 +3220,8 @@ const WorkflowContent = React.memo( */ const onConnectEnd = useCallback( (event: MouseEvent | TouchEvent) => { - setIsErrorConnectionDrag(false) + canvasContainerRef.current?.setAttribute('data-connection-line', 'default') + canvasContainerRef.current?.setAttribute('data-connection-active', 'false') const source = connectionSourceRef.current if (!source?.nodeId) { @@ -3204,22 +3238,57 @@ const WorkflowContent = React.memo( // Find node under cursor using DOM hit-testing const clientPos = 'changedTouches' in event ? event.changedTouches[0] : event const targetNode = findNodeAtScreenPosition(clientPos.clientX, clientPos.clientY) + const sourceHandle = normalizeCursorSourceHandleId(source.handleId) ?? 'source' // Create connection if valid target found (handle-to-body case) if (targetNode && targetNode.id !== source.nodeId) { - onConnect({ - source: source.nodeId, - sourceHandle: source.handleId, - target: targetNode.id, - targetHandle: 'target', - }) + /* + * Connections are horizontal-only. A body drop resolves by card + * half, so even a top/bottom drop terminates at the nearest left or + * right anchor without reintroducing a vertical edge endpoint. + */ + const nodeEl = document.querySelector( + `.react-flow__node[data-id="${targetNode.id}"]` + ) as HTMLElement | null + let dropSide: PositionedSourceHandleSide | null = null + if (nodeEl) { + const rect = nodeEl.getBoundingClientRect() + dropSide = getHorizontalWorkflowHandleSide(clientPos.clientX - rect.left, rect.width) + } + /* + * A drag can start on an INPUT knob (the left target). The dropped + * card is then the upstream end: reusing the origin as the edge + * source would store 'target' as a sourceHandle, and the edge + * renders off arbitrary fallback anchors and never highlights. + */ + const draggedFromInput = + source.handleId === 'target' || isPositionedTargetHandle(source.handleId) + if (draggedFromInput) { + const dropSourceHandle = + !dropSide || dropSide === 'right' ? 'source' : getPositionedSourceHandleId(dropSide) + onConnect({ + source: targetNode.id, + sourceHandle: dropSourceHandle, + target: source.nodeId, + targetHandle: source.handleId ?? 'target', + }) + } else { + const targetHandle = + !dropSide || dropSide === 'left' ? 'target' : getPositionedTargetHandleId(dropSide) + onConnect({ + source: source.nodeId, + sourceHandle, + target: targetNode.id, + targetHandle, + }) + } } else if (!targetNode) { // Released on empty canvas: open the command palette with the drag origin // + drop point, so the chosen block lands here wired from this handle. useSearchModalStore.getState().open({ sections: ['blocks', 'tools', 'toolOperations'], pendingConnect: { - source: { nodeId: source.nodeId, handleId: source.handleId }, + source: { nodeId: source.nodeId, handleId: sourceHandle }, screenX: clientPos.clientX, screenY: clientPos.clientY, }, @@ -3228,7 +3297,7 @@ const WorkflowContent = React.memo( connectionSourceRef.current = null }, - [findNodeAtScreenPosition, onConnect] + [findNodeAtScreenPosition, onConnect, blocks] ) /** Handles node drag to detect container intersections and update highlighting. */ @@ -3860,6 +3929,32 @@ const WorkflowContent = React.memo( usePanelEditorStore.getState().clearCurrentBlock() }, []) + /** + * Animates the camera so the given block centers in the canvas frame. + * Deferred by two animation frames because selection can switch the right + * panel to the Editor tab, resizing the canvas — centering must run + * against the settled frame size or the card lands off-center. Zooms in + * to 1 when zoomed out; keeps the user's zoom when already closer. + */ + const focusBlockInView = useCallback( + (node: Node) => { + const position = node.positionAbsolute ?? node.position + const nodeWidth = node.width ?? 250 + const nodeHeight = node.height ?? 100 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const currentZoom = reactFlowInstance.getViewport().zoom + const targetZoom = Math.max(currentZoom, 1) + reactFlowInstance.setCenter(position.x + nodeWidth / 2, position.y + nodeHeight / 2, { + zoom: targetZoom, + duration: 500, + }) + }) + }) + }, + [reactFlowInstance] + ) + /** * Handles node click to select the node in ReactFlow. * Uses the controlled display node state so parent-child conflicts are resolved @@ -3893,10 +3988,85 @@ const WorkflowContent = React.memo( })) return resolveSelectionConflicts(updated, blocks, isMultiSelect ? node.id : undefined) }) + + /** + * Focus the clicked block: animate the camera so the card centers in + * the canvas frame. Only on plain clicks of regular blocks — + * multi-select and subflow containers keep the camera still. + * onNodeClick never fires after a drag. + */ + if (!embedded && !isMultiSelect && node.type === 'workflowBlock') { + focusBlockInView(node) + } }, - [blocks, getNodes] + [blocks, getNodes, embedded, focusBlockInView] ) + /** + * Arrow-key navigation: with a single block selected, Right/Down moves to + * the next block in canvas reading order (left-to-right, top-to-bottom) + * and Left/Up to the previous, wrapping at the ends. Selection, the + * editor panel, and the camera focus all follow. Skipped while typing in + * inputs/editors or interacting with menus, and in embedded mode. + */ + useEffect(() => { + if (embedded) return + + const handleArrowNavigation = (event: KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return + const isNext = event.key === 'ArrowRight' || event.key === 'ArrowDown' + const isPrev = event.key === 'ArrowLeft' || event.key === 'ArrowUp' + if (!isNext && !isPrev) return + + const target = event.target as HTMLElement | null + if ( + target && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.isContentEditable || + target.closest( + '[contenteditable="true"], [role="listbox"], [role="menu"], [role="combobox"], .cm-editor' + )) + ) { + return + } + + const workflowNodes = getNodes().filter((n) => n.type === 'workflowBlock') + const selected = workflowNodes.filter((n) => n.selected) + if (selected.length !== 1 || workflowNodes.length < 2) return + + const ordered = [...workflowNodes].sort((a, b) => { + const pa = a.positionAbsolute ?? a.position + const pb = b.positionAbsolute ?? b.position + return pa.x - pb.x || pa.y - pb.y + }) + const currentIndex = ordered.findIndex((n) => n.id === selected[0].id) + if (currentIndex === -1) return + + event.preventDefault() + event.stopPropagation() + + const nextNode = + ordered[(currentIndex + (isNext ? 1 : -1) + ordered.length) % ordered.length] + + setDisplayNodes((currentNodes) => + resolveSelectionConflicts( + currentNodes.map((currentNode) => ({ + ...currentNode, + selected: currentNode.id === nextNode.id, + })), + blocks + ) + ) + usePanelEditorStore.getState().setCurrentBlockId(nextNode.id) + focusBlockInView(nextNode) + } + + window.addEventListener('keydown', handleArrowNavigation, true) + return () => window.removeEventListener('keydown', handleArrowNavigation, true) + }, [embedded, getNodes, blocks, focusBlockInView]) + /** Handles edge selection with container context tracking and Shift-click multi-selection. */ const onEdgeClick = useCallback( (event: React.MouseEvent, edge: any) => { @@ -3980,39 +4150,35 @@ const WorkflowContent = React.memo( /** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */ const edgesWithSelection = useMemo(() => { const nodeMap = new Map(displayNodes.map((n) => [n.id, n])) - const elevatedNodeIdSet = new Set( - lastInteractedNodeId ? [...selectedNodeIds, lastInteractedNodeId] : selectedNodeIds - ) return edgesForDisplay.map((edge) => { const sourceNode = nodeMap.get(edge.source) const targetNode = nodeMap.get(edge.target) const parentLoopId = sourceNode?.parentId || targetNode?.parentId const edgeContextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}` - const connectedToElevated = - elevatedNodeIdSet.has(edge.source) || elevatedNodeIdSet.has(edge.target) - // Derive elevated z-index from connected nodes so edges inside subflows - // (child nodes at z-1000) stay above their sibling child blocks. - const elevatedZIndex = Math.max( - 22, - (sourceNode?.zIndex ?? 21) + 1, - (targetNode?.zIndex ?? 21) + 1 - ) // Edges inside subflows need a z-index above the container's body area // (which has pointer-events: auto) so they're directly clickable. // Derive from the container's depth-based zIndex (+1) so the edge sits // just above its parent container but below canvas blocks (z-21+) and // child blocks (z-1000). + // + // Edges are NEVER elevated above cards — not even when an endpoint is + // selected. A line always passes behind cards, knobs, and the action + // bar swell; elevating highlighted edges drew them across their own + // endpoint's chrome. const containerNode = parentLoopId ? nodeMap.get(parentLoopId) : null const baseZIndex = containerNode ? (containerNode.zIndex ?? 0) + 1 : 0 + const isConnectedToSelection = + selectedNodeIds.includes(edge.source) || selectedNodeIds.includes(edge.target) return { ...edge, - zIndex: connectedToElevated ? elevatedZIndex : baseZIndex, + zIndex: baseZIndex, data: { ...edge.data, isSelected: selectedEdges.has(edgeContextId), + isConnectedToSelection, isInsideLoop: Boolean(parentLoopId), parentLoopId, sourceHandle: edge.sourceHandle, @@ -4020,14 +4186,7 @@ const WorkflowContent = React.memo( }, } }) - }, [ - edgesForDisplay, - displayNodes, - selectedNodeIds, - selectedEdges, - handleEdgeDelete, - lastInteractedNodeId, - ]) + }, [edgesForDisplay, displayNodes, selectedNodeIds, selectedEdges, handleEdgeDelete]) /** Handles Delete/Backspace to remove selected edges or blocks. */ useEffect(() => { @@ -4138,7 +4297,10 @@ const WorkflowContent = React.memo( return (
-
+
{!isWorkflowReady && (
+ side === 'left' ? Position.Left : Position.Right + +const getCenteredSideHandleStyle = (side: PositionedSourceHandleSide): CSSProperties => ({ + right: 'auto', + bottom: 'auto', + width: 1, + height: 1, + top: '50%', + left: side === 'left' ? 0 : '100%', + transform: 'translate(-50%, -50%)', +}) + interface WorkflowPreviewBlockData { type: string name: string @@ -178,7 +198,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps workflowMap = {}, workflowLabelsReady = false, isTrigger = false, - horizontalHandles = false, enabled = true, isPreviewSelected = false, executionStatus, @@ -227,8 +246,10 @@ function WorkflowPreviewBlockInner({ data }: NodeProps if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) { return false } - if (!subBlock.condition) return true - return evaluateSubBlockCondition(subBlock.condition, rawValues) + if (subBlock.condition && !evaluateSubBlockCondition(subBlock.condition, rawValues)) { + return false + } + return hasDisplayableRowValue(subBlock, rawValues[subBlock.id]) }) }, [ lightweight, @@ -335,59 +356,50 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const hasError = executionStatus === 'error' const hasSuccess = executionStatus === 'success' + const typeAccent = getWorkflowTypeAccent(type) return ( -
+
{/* Selection ring overlay (takes priority over execution rings) */} {isPreviewSelected && ( -
+
)} {/* Success ring overlay (only shown if not selected) */} {!isPreviewSelected && hasSuccess && ( -
+
)} {/* Error ring overlay (only shown if not selected) */} {!isPreviewSelected && hasError && ( -
+
)} {/* Target handle - not shown for triggers/starters */} {shouldShowDefaultHandles && ( )} {/* Header - matches WorkflowBlock structure */} -
-
- {!isNoteBlock && ( -
- -
- )} +
+
- {name} + {humanizeBlockName(name)}
+ {!isNoteBlock && ( + + + {blockConfig.name !== name ? blockConfig.name : null} + + )}
{/* Content area with subblocks */} @@ -438,14 +450,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps ) }) )} - {/* Error row for non-trigger blocks */} - {shouldShowDefaultHandles && ( - - )}
)} @@ -466,13 +470,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps /> ) })} - )} @@ -494,13 +491,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps /> ) })} - )} @@ -509,26 +499,49 @@ function WorkflowPreviewBlockInner({ data }: NodeProps <> - {shouldShowDefaultHandles && ( + {POSITIONED_SOURCE_HANDLE_SIDES.map((side) => (
) } @@ -549,7 +562,6 @@ function shouldSkipPreviewBlockRender( prevProps.data.type !== nextProps.data.type || prevProps.data.name !== nextProps.data.name || prevProps.data.isTrigger !== nextProps.data.isTrigger || - prevProps.data.horizontalHandles !== nextProps.data.horizontalHandles || prevProps.data.enabled !== nextProps.data.enabled || prevProps.data.isPreviewSelected !== nextProps.data.isPreviewSelected || prevProps.data.executionStatus !== nextProps.data.executionStatus || diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx index 551c504cfb5..cf6bf094367 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/command-items/command-items.tsx @@ -2,8 +2,9 @@ import type { ComponentType } from 'react' import { memo } from 'react' -import { cn } from '@sim/emcn' +import { ChipTag, cn } from '@sim/emcn' import { File, Workflow } from '@sim/emcn/icons' +import { getWorkflowTypeAccent } from '@sim/workflow-renderer' import { Command } from 'cmdk' import type { CommandItemProps } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' import { COMMAND_ITEM_CLASSNAME } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils' @@ -16,23 +17,36 @@ export const MemoizedCommandItem = memo( icon: Icon, bgColor, showColoredIcon, + workflowType, label, }: CommandItemProps) { + const workflowAccent = workflowType ? getWorkflowTypeAccent(workflowType) : null + return ( -
- -
+ {workflowAccent ? ( + + + + ) : ( +
+ +
+ )} {label}
) @@ -42,6 +56,7 @@ export const MemoizedCommandItem = memo( prev.icon === next.icon && prev.bgColor === next.bgColor && prev.showColoredIcon === next.showColoredIcon && + prev.workflowType === next.workflowType && prev.label === next.label ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx index f8e071be9d0..2788d6e8a04 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/components/search-groups/search-groups.tsx @@ -72,6 +72,7 @@ export const BlocksGroup = memo(function BlocksGroup({ icon={block.icon} bgColor={block.bgColor} showColoredIcon + workflowType={block.type} label={block.name} /> ))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts index 5b046c31a52..e0af331e9b3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.ts @@ -91,6 +91,8 @@ export interface CommandItemProps { icon: ComponentType<{ className?: string }> bgColor: string showColoredIcon?: boolean + /** Core workflow block type whose shared accent should replace the catalog color. */ + workflowType?: string /** Primary text of the row. */ label: string } diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index a10c0acf1f9..4dbb16638a7 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1518,12 +1518,12 @@ export function InputIcon(props: SVGProps) { export function StartIcon(props: SVGProps) { return ( { expect(readyNodes).toContain(successTargetId) expect(readyNodes).not.toContain(errorTargetId) }) + + it('treats positioned source handles as success edges', () => { + const sourceId = 'source-1' + const successTargetId = 'success-target' + const errorTargetId = 'error-target' + const sourceNode = createMockNode(sourceId, [ + { target: successTargetId, sourceHandle: 'source-top' }, + { target: errorTargetId, sourceHandle: 'error' }, + ]) + const nodes = new Map([ + [sourceId, sourceNode], + [successTargetId, createMockNode(successTargetId, [], [sourceId])], + [errorTargetId, createMockNode(errorTargetId, [], [sourceId])], + ]) + const edgeManager = new EdgeManager(createMockDAG(nodes)) + + const readyNodes = edgeManager.processOutgoingEdges(sourceNode, { + error: 'Something went wrong', + }) + + expect(readyNodes).toContain(errorTargetId) + expect(readyNodes).not.toContain(successTargetId) + }) }) describe('Router edge handling', () => { diff --git a/apps/sim/executor/execution/edge-manager.ts b/apps/sim/executor/execution/edge-manager.ts index 148d88a352e..7454311b4c2 100644 --- a/apps/sim/executor/execution/edge-manager.ts +++ b/apps/sim/executor/execution/edge-manager.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isPositionedSourceHandle } from '@sim/workflow-types/workflow' import { CONTROL_BACK_EDGE_HANDLES, EDGE, SUBFLOW_CONTROL_EDGE_HANDLES } from '@/executor/constants' import type { DAG, DAGNode } from '@/executor/dag/builder' import type { DAGEdge } from '@/executor/dag/types' @@ -286,6 +287,10 @@ export class EdgeManager { return output.selectedRoute === routeId } + if (isPositionedSourceHandle(handle)) { + return !output.error + } + switch (handle) { case EDGE.ERROR: return !!output.error diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index e0c752bb446..e9509d2149e 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -65,6 +65,7 @@ const logger = createLogger('CollaborativeWorkflow') export function useCollaborativeWorkflow() { const queryClient = useQueryClient() const undoRedo = useUndoRedo() + const recordBatchRemoveEdges = undoRedo.recordBatchRemoveEdges const isUndoRedoInProgress = useRef(false) const lastDiffOperationId = useRef(null) @@ -231,6 +232,9 @@ export function useCollaborativeWorkflow() { case BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE: useWorkflowStore.getState().setBlockAdvancedMode(payload.id, payload.advancedMode) break + case BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED: + useWorkflowStore.getState().setBlockErrorEnabled(payload.id, payload.errorEnabled) + break case BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE: useWorkflowStore .getState() @@ -1282,6 +1286,18 @@ export function useCollaborativeWorkflow() { [executeQueuedOperation] ) + const collaborativeSetBlockErrorEnabled = useCallback( + (id: string, errorEnabled: boolean) => { + executeQueuedOperation( + BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED, + OPERATION_TARGETS.BLOCK, + { id, errorEnabled }, + () => useWorkflowStore.getState().setBlockErrorEnabled(id, errorEnabled) + ) + }, + [executeQueuedOperation] + ) + const collaborativeSetBlockCanonicalMode = useCallback( (id: string, canonicalId: string, canonicalMode: 'basic' | 'advanced') => { if (isBaselineDiffView) { @@ -1530,13 +1546,13 @@ export function useCollaborativeWorkflow() { useWorkflowStore.getState().batchRemoveEdges(validEdgeIds) if (!options?.skipUndoRedo && edgeSnapshots.length > 0) { - undoRedo.recordBatchRemoveEdges(edgeSnapshots) + recordBatchRemoveEdges(edgeSnapshots) } logger.info('Batch removed edges', { count: validEdgeIds.length }) return true }, - [isBaselineDiffView, addToQueue, activeWorkflowId, session, undoRedo] + [isBaselineDiffView, addToQueue, activeWorkflowId, session, recordBatchRemoveEdges] ) const collaborativeSetSubblockValue = useCallback( @@ -2236,6 +2252,7 @@ export function useCollaborativeWorkflow() { collaborativeBatchToggleBlockEnabled, collaborativeBatchUpdateParent, collaborativeToggleBlockAdvancedMode, + collaborativeSetBlockErrorEnabled, collaborativeSetBlockCanonicalMode, collaborativeSetBlockCanonicalModes, collaborativeBatchToggleBlockHandles, diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 6920db4f5d5..4e14c793cdf 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -33,6 +33,7 @@ const workflowBlockDataSchema = z.object({ batchSize: z.number().optional(), type: z.string().optional(), canonicalModes: z.record(z.string(), z.enum(['basic', 'advanced'])).optional(), + errorEnabled: z.boolean().optional(), }) const workflowSubBlockStateSchema = z.object({ @@ -58,6 +59,7 @@ const workflowBlockStateSchema = z.object({ horizontalHandles: z.boolean().optional(), height: z.number().optional(), advancedMode: z.boolean().optional(), + errorEnabled: z.boolean().optional(), triggerMode: z.boolean().optional(), data: workflowBlockDataSchema.optional(), locked: z.boolean().optional(), diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 9dd6e65a806..77eb01a07f0 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' +import { isPositionedSourceHandle } from '@sim/workflow-types/workflow' import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' @@ -661,7 +662,11 @@ export function validateSourceHandleForBlock( } case 'router': - if (sourceHandle === 'source' || sourceHandle.startsWith(EDGE.ROUTER_PREFIX)) { + if ( + sourceHandle === 'source' || + isPositionedSourceHandle(sourceHandle) || + sourceHandle.startsWith(EDGE.ROUTER_PREFIX) + ) { return { valid: true } } return { @@ -684,7 +689,7 @@ export function validateSourceHandleForBlock( } default: - if (sourceHandle === 'source') { + if (sourceHandle === 'source' || isPositionedSourceHandle(sourceHandle)) { return { valid: true } } return { diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts index 94035ebe90d..e516961396b 100644 --- a/apps/sim/lib/workflows/autolayout/core.ts +++ b/apps/sim/lib/workflows/autolayout/core.ts @@ -26,7 +26,7 @@ const SUBFLOW_START_HANDLES = new Set(['loop-start-source', 'parallel-start-sour function getSourceHandleYOffset(block: BlockState, sourceHandle?: string | null): number { if (sourceHandle === 'error') { const blockHeight = block.height || BLOCK_DIMENSIONS.MIN_HEIGHT - return blockHeight - HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET + return blockHeight } if (sourceHandle && SUBFLOW_START_HANDLES.has(sourceHandle)) { @@ -52,14 +52,25 @@ function getSourceHandleYOffset(block: BlockState, sourceHandle?: string | null) } } - return HANDLE_POSITIONS.DEFAULT_Y_OFFSET + return getDefaultHandleYOffset(block) +} + +/** + * Default handle Y for a block: regular cards anchor their side ports at the + * vertical center; subflow containers keep the fixed top offset. + */ +function getDefaultHandleYOffset(block: BlockState): number { + if (block.type === 'loop' || block.type === 'parallel') { + return HANDLE_POSITIONS.DEFAULT_Y_OFFSET + } + return (block.height || BLOCK_DIMENSIONS.MIN_HEIGHT) / 2 } /** * Calculates the Y offset for a target handle based on block type and handle ID. */ -function getTargetHandleYOffset(_block: BlockState, _targetHandle?: string | null): number { - return HANDLE_POSITIONS.DEFAULT_Y_OFFSET +function getTargetHandleYOffset(block: BlockState, _targetHandle?: string | null): number { + return getDefaultHandleYOffset(block) } /** diff --git a/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts b/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts index b3d8145e91b..fd42e24e2eb 100644 --- a/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts +++ b/apps/sim/lib/workflows/blocks/deterministic-dimensions.ts @@ -8,36 +8,82 @@ interface WorkflowBlockDimensionsInput { visibleSubBlockCount: number conditionRowCount?: number routerRowCount?: number + /** Number of value pills in the chips row (standard blocks only). */ + chipCount?: number + /** + * Estimated wrapped line count of the natural-language summary. When > 0 + * the summary replaces the chips row and all field rows. + */ + sentenceLineCount?: number + /** + * Whether the card carries the error-output row. It is permanent for blocks + * that can emit an error — it is the affordance for switching the output on — + * so it holds a row whether or not the toggle is set. + */ + hasErrorRow?: boolean } export function calculateWorkflowBlockDimensions({ blockType, - category, - displayTriggerMode, visibleSubBlockCount, conditionRowCount = 0, routerRowCount = 0, + chipCount = 0, + sentenceLineCount = 0, + hasErrorRow = false, }: WorkflowBlockDimensionsInput): { width: number; height: number } { - const shouldShowDefaultHandles = - category !== 'triggers' && blockType !== 'starter' && !displayTriggerMode - const defaultHandlesRow = shouldShowDefaultHandles ? 1 : 0 + const isBranchBlock = blockType === 'condition' || blockType === 'router_v2' let rowsCount = 0 if (blockType === 'condition') { - rowsCount = conditionRowCount + defaultHandlesRow + rowsCount = conditionRowCount } else if (blockType === 'router_v2') { - rowsCount = 1 + routerRowCount + defaultHandlesRow + rowsCount = 1 + routerRowCount } else { - rowsCount = visibleSubBlockCount + defaultHandlesRow + rowsCount = visibleSubBlockCount } - const hasContentBelowHeader = rowsCount > 0 + const hasSentence = !isBranchBlock && sentenceLineCount > 0 + const chipsRowHeight = + !isBranchBlock && !hasSentence && chipCount > 0 ? BLOCK_DIMENSIONS.WORKFLOW_CHIPS_ROW_HEIGHT : 0 + + /* + * The content column is `flex flex-col gap-2 p-2`, so its height is the sum + * of the sections it renders plus one gap between each adjacent pair. Adding + * the sections without the gaps (or applying a per-row pitch that bakes one + * in) over-estimates, and the card's silhouette is painted from this number — + * an over-estimate draws a card taller than its own content. + */ + const sections: number[] = [] + if (chipsRowHeight > 0) sections.push(chipsRowHeight) + if (hasSentence) { + sections.push(sentenceLineCount * BLOCK_DIMENSIONS.WORKFLOW_SENTENCE_LINE_HEIGHT) + } else { + for (let index = 0; index < rowsCount; index++) { + sections.push(BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT) + } + } + if (hasErrorRow) sections.push(BLOCK_DIMENSIONS.WORKFLOW_ERROR_ROW_HEIGHT) + const hasContentBelowHeader = sections.length > 0 const contentHeight = hasContentBelowHeader - ? BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING + rowsCount * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT + ? BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING + + sections.reduce((total, section) => total + section, 0) + + (sections.length - 1) * BLOCK_DIMENSIONS.WORKFLOW_CONTENT_GAP : 0 + /* + * The old `MIN_HEIGHT` (100) floor is gone: it stretched a header-only + * trigger card to more than twice its content and painted an empty band + * under the title. Consumers that need a minimum for canvas math (selection + * bounds, paste placement) already clamp with MIN_HEIGHT themselves. + * + * `MIN_PAINTED_HEIGHT` (48) is the shortest silhouette the border renderer + * will paint. Header-only cards (40px header) must still use this floor — + * and size their DOM host to match — or `preserveAspectRatio='none'` + * squashes the action-menu tab and leaves uneven gray under the icons. + */ const height = Math.max( BLOCK_DIMENSIONS.HEADER_HEIGHT + contentHeight, - BLOCK_DIMENSIONS.MIN_HEIGHT + BLOCK_DIMENSIONS.MIN_PAINTED_HEIGHT ) return { diff --git a/apps/sim/lib/workflows/blocks/workflow-block-border-mount.test.tsx b/apps/sim/lib/workflows/blocks/workflow-block-border-mount.test.tsx new file mode 100644 index 00000000000..061624fbe36 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/workflow-block-border-mount.test.tsx @@ -0,0 +1,281 @@ +/** + * @vitest-environment jsdom + * + * Mount smoke test for the border renderer. It lives in apps/sim because the + * renderer package has no test runner. The coloured-port case exists because + * a knob-paint bug once threw only when a card had a coloured knob — invisible + * on an idle canvas, fatal on node creation. + */ +import { act } from 'react' +import { + ERROR_SOURCE_HANDLE_POSITION, + getCursorBranchSourceHandleId, + getCursorSourceHandlePosition, + getErrorBorderPort, + getErrorSourceHandleStyle, + getNearestBranchCursorHandleId, + getWorkflowBorderFrameDeltaSeconds, + HANDLE_POSITIONS, + isActionMenuSwellReady, + normalizeCursorSourceHandleId, + WorkflowBlockBorder, + type WorkflowBorderPort, +} from '@sim/workflow-renderer' +import { + getHorizontalWorkflowHandleSide, + normalizePositionedSourceHandleId, + normalizePositionedTargetHandleId, + POSITIONED_SOURCE_HANDLE_SIDES, +} from '@sim/workflow-types/workflow' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + onchange: null, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia +}) + +const ports: WorkflowBorderPort[] = [ + { id: 'target', side: 'left', position: 'center', plateau: 33 }, + { id: 'source', side: 'right', position: 'center', plateau: 33 }, + { + id: 'error', + side: 'bottom', + position: { fromEnd: HANDLE_POSITIONS.ERROR_RIGHT_OFFSET }, + plateau: 24, + color: 'var(--text-secondary)', + }, + { + id: 'action-menu', + side: 'top', + position: { fromEnd: 24 + 82 }, + plateau: 164, + restAmplitude: 7, + hoverAmplitude: 7, + magnetizable: false, + }, +] + +const mountedRoots = new Set() +const mountedHosts = new Set() + +function mount(element: React.ReactElement) { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + mountedRoots.add(root) + mountedHosts.add(host) + act(() => { + root.render(element) + }) + return { host, root } +} + +afterEach(() => { + act(() => { + mountedRoots.forEach((root) => root.unmount()) + }) + mountedRoots.clear() + mountedHosts.forEach((host) => host.remove()) + mountedHosts.clear() + vi.unstubAllGlobals() +}) + +describe('WorkflowBlockBorder mount', () => { + it('resolves every connection start to a horizontal source anchor', () => { + expect(POSITIONED_SOURCE_HANDLE_SIDES).toEqual(['left', 'right']) + expect(getHorizontalWorkflowHandleSide(20, 250)).toBe('left') + expect(getHorizontalWorkflowHandleSide(124.9, 250)).toBe('left') + expect(getHorizontalWorkflowHandleSide(125, 250)).toBe('right') + expect(getHorizontalWorkflowHandleSide(230, 250)).toBe('right') + expect(normalizeCursorSourceHandleId('source-cursor-left')).toBe('source-left') + expect(normalizeCursorSourceHandleId('source-cursor-right')).toBe('source-right') + expect(normalizeCursorSourceHandleId('source-cursor-top')).toBe('source-right') + expect(normalizeCursorSourceHandleId('source-cursor-bottom')).toBe('source-right') + }) + + it('uses the grabbed edge only for the transient preview direction', () => { + expect(getCursorSourceHandlePosition('top')).toBe('top') + expect(getCursorSourceHandlePosition('bottom')).toBe('bottom') + expect(getCursorSourceHandlePosition('left')).toBe('left') + expect(getCursorSourceHandlePosition('right')).toBe('right') + }) + + it('keeps moving branch swells attached to a valid branch output', () => { + const conditionRows = [{ id: 'if-id' }, { id: 'else-if-id' }, { id: 'else-id' }] + + expect(getNearestBranchCursorHandleId(conditionRows, 0, 60, 'condition')).toBe( + getCursorBranchSourceHandleId('condition-if-id') + ) + expect(getNearestBranchCursorHandleId(conditionRows, 90, 60, 'condition')).toBe( + getCursorBranchSourceHandleId('condition-else-if-id') + ) + expect(getNearestBranchCursorHandleId(conditionRows, 200, 60, 'condition')).toBe( + getCursorBranchSourceHandleId('condition-else-id') + ) + expect(normalizeCursorSourceHandleId(getCursorBranchSourceHandleId('condition-else-id'))).toBe( + 'condition-else-id' + ) + }) + + it('keeps Error as the bottom-right persisted source exception', () => { + expect(getErrorBorderPort('var(--text-secondary)')).toEqual({ + id: 'error', + side: 'bottom', + position: { fromEnd: 30 }, + plateau: 24, + color: 'var(--text-secondary)', + }) + expect(ERROR_SOURCE_HANDLE_POSITION).toBe('bottom') + expect(getErrorSourceHandleStyle()).toEqual({ + right: 'auto', + top: 'auto', + bottom: -7, + left: 'calc(100% - 30px)', + width: 24, + height: 14, + transform: 'translateX(-50%)', + }) + expect(normalizeCursorSourceHandleId('error')).toBe('error') + }) + + it('collapses legacy vertical edge anchors during persistence normalization', () => { + expect(normalizePositionedSourceHandleId('source-top')).toBe('source-right') + expect(normalizePositionedSourceHandleId('source-bottom')).toBe('source-right') + expect(normalizePositionedTargetHandleId('target-top')).toBe('target-left') + expect(normalizePositionedTargetHandleId('target-bottom')).toBe('target-left') + }) + + it('never advances a spring with a negative or unbounded frame delta', () => { + expect(getWorkflowBorderFrameDeltaSeconds(90, 100)).toBe(0) + expect(getWorkflowBorderFrameDeltaSeconds(Number.NaN, 100)).toBe(0) + expect(getWorkflowBorderFrameDeltaSeconds(10_000, 100)).toBeCloseTo(1 / 30) + }) + + it('reveals action-menu content only after the opening swell is ready', () => { + expect(isActionMenuSwellReady(1, 0.79)).toBe(false) + expect(isActionMenuSwellReady(1, 0.8)).toBe(true) + expect(isActionMenuSwellReady(1, 1)).toBe(true) + expect(isActionMenuSwellReady(0, 1)).toBe(false) + }) + + it('paints synchronously at mount without throwing', () => { + const { host } = mount( +
+ +
+ ) + const path = host.querySelector('svg path') + expect(path?.getAttribute('d')?.length ?? 0).toBeGreaterThan(0) + }) + + it('mounts a header-only card without throwing', () => { + const { host } = mount( +
+ +
+ ) + expect(host.querySelector('svg')).toBeTruthy() + }) + + it('keeps every path coordinate bounded when animation timestamps move backward', () => { + const callbacks = new Map() + let nextFrameId = 0 + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + nextFrameId += 1 + callbacks.set(nextFrameId, callback) + return nextFrameId + }) + ) + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((frameId: number) => { + callbacks.delete(frameId) + }) + ) + + const { host } = mount( +
+ +
+ ) + const initialTimestamp = performance.now() + + for (let index = 1; index <= 24; index++) { + const next = callbacks.entries().next().value as [number, FrameRequestCallback] | undefined + expect(next).toBeDefined() + if (!next) break + callbacks.delete(next[0]) + act(() => next[1](initialTimestamp - index * 33)) + } + + const pathData = [...host.querySelectorAll('svg path')] + .map((path) => path.getAttribute('d') ?? '') + .join(' ') + const coordinates = pathData.match(/-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/gi)?.map(Number) ?? [] + expect(pathData).not.toMatch(/NaN|Infinity/) + expect(coordinates.every(Number.isFinite)).toBe(true) + expect(Math.max(...coordinates.map(Math.abs))).toBeLessThanOrEqual(278.1) + }) + + it('keeps only one animation frame scheduled across synchronous geometry repaints', () => { + const callbacks = new Map() + let nextFrameId = 0 + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + nextFrameId += 1 + callbacks.set(nextFrameId, callback) + return nextFrameId + }) + ) + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((frameId: number) => { + callbacks.delete(frameId) + }) + ) + + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + mountedRoots.add(root) + mountedHosts.add(host) + act(() => { + root.render( +
+ +
+ ) + }) + expect(callbacks.size).toBe(1) + + const updatedPorts = ports.map((port) => + port.id === 'source' ? { ...port, color: 'var(--text-secondary)' } : port + ) + act(() => { + root.render( +
+ +
+ ) + }) + + expect(callbacks.size).toBe(1) + }) +}) diff --git a/apps/sim/lib/workflows/comparison/compare.ts b/apps/sim/lib/workflows/comparison/compare.ts index 0b402b913f7..2bf81ad3411 100644 --- a/apps/sim/lib/workflows/comparison/compare.ts +++ b/apps/sim/lib/workflows/comparison/compare.ts @@ -195,7 +195,12 @@ export function generateWorkflowDiffSummary( newValue: currentBlock.enabled, }) } - const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode'] as const + const blockFields = [ + 'horizontalHandles', + 'advancedMode', + 'triggerMode', + 'errorEnabled', + ] as const for (const field of blockFields) { if (!!currentBlock[field] !== !!previousBlock[field]) { changes.push({ diff --git a/apps/sim/lib/workflows/defaults.ts b/apps/sim/lib/workflows/defaults.ts index c326ca43a64..29407ab1eeb 100644 --- a/apps/sim/lib/workflows/defaults.ts +++ b/apps/sim/lib/workflows/defaults.ts @@ -94,6 +94,7 @@ function buildStartBlockState( enabled: true, horizontalHandles: true, advancedMode: false, + errorEnabled: false, triggerMode: false, height: 0, data: {}, diff --git a/apps/sim/lib/workflows/diff/diff-engine.ts b/apps/sim/lib/workflows/diff/diff-engine.ts index fa6c0c44b77..75c0fc6093b 100644 --- a/apps/sim/lib/workflows/diff/diff-engine.ts +++ b/apps/sim/lib/workflows/diff/diff-engine.ts @@ -42,6 +42,7 @@ function hasBlockChanged(currentBlock: BlockState, proposedBlock: BlockState): b if (currentBlock.name !== proposedBlock.name) return true if (currentBlock.enabled !== proposedBlock.enabled) return true if (currentBlock.triggerMode !== proposedBlock.triggerMode) return true + if (currentBlock.errorEnabled !== proposedBlock.errorEnabled) return true if ((currentBlock.data?.parentId ?? null) !== (proposedBlock.data?.parentId ?? null)) return true // Compare subBlocks @@ -73,7 +74,14 @@ function computeFieldDiff( const unchangedFields: string[] = [] // Check basic fields - const fieldsToCheck = ['type', 'name', 'enabled', 'triggerMode', 'horizontalHandles'] as const + const fieldsToCheck = [ + 'type', + 'name', + 'enabled', + 'triggerMode', + 'horizontalHandles', + 'errorEnabled', + ] as const for (const field of fieldsToCheck) { const currentValue = currentBlock[field] const proposedValue = proposedBlock[field] diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 2d4f09bba40..b345bcede82 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -1,4 +1,5 @@ import { isRecordLike, sortObjectKeysDeep } from '@sim/utils/object' +import { isPositionedSourceHandle } from '@sim/workflow-types/workflow' import type { Edge } from 'reactflow' import { getBaseUrl } from '@/lib/core/utils/urls' import { sanitizeWorkflowForSharing } from '@/lib/workflows/credentials/credential-extractor' @@ -33,6 +34,7 @@ interface CopilotBlockState { nestedNodes?: Record enabled: boolean advancedMode?: boolean + errorEnabled?: boolean triggerMode?: boolean } @@ -491,7 +493,9 @@ function extractConnectionsForBlock( // Group by source handle (converting to simple format) for (const edge of outgoingEdges) { - let handle = edge.sourceHandle || 'source' + let handle = isPositionedSourceHandle(edge.sourceHandle) + ? 'source' + : edge.sourceHandle || 'source' // Convert internal UUID handles to simple format (if, else-if-0, route-0, etc.) handle = convertToSimpleHandle(handle, blockId, block) @@ -603,6 +607,7 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState { if (connections) result.connections = connections if (Object.keys(nestedNodes).length > 0) result.nestedNodes = nestedNodes if (block.advancedMode !== undefined) result.advancedMode = block.advancedMode + if (block.errorEnabled !== undefined) result.errorEnabled = block.errorEnabled if (block.triggerMode !== undefined) result.triggerMode = block.triggerMode // Note: outputs, position, height, layout, horizontalHandles are intentionally excluded diff --git a/apps/sim/lib/workflows/subblocks/display.ts b/apps/sim/lib/workflows/subblocks/display.ts index 26088c25fff..2399875a4f2 100644 --- a/apps/sim/lib/workflows/subblocks/display.ts +++ b/apps/sim/lib/workflows/subblocks/display.ts @@ -311,6 +311,18 @@ export const getDisplayValue = (value: unknown): string => { return stringValue.trim().length > 0 ? stringValue : '-' } +/** + * Whether a collapsed-node row has a meaningful value to display. + * Rows whose value renders as the empty placeholder are hidden from the + * node preview so blocks only surface configured fields. + * `webhookUrlDisplay*` rows derive their value from the block id rather than + * the stored value, so they always count as displayable. + */ +export function hasDisplayableRowValue(subBlock: SubBlockConfig, rawValue: unknown): boolean { + if (subBlock.id.startsWith('webhookUrlDisplay')) return true + return getDisplayValue(rawValue) !== '-' +} + /** * Workflow id -> metadata lookup for the workflow selector resolvers. * `ready` gates resolution so missing entries only render as deleted once diff --git a/apps/sim/stores/workflows/workflow/store.test.ts b/apps/sim/stores/workflows/workflow/store.test.ts index f5072971faf..c0066dff668 100644 --- a/apps/sim/stores/workflows/workflow/store.test.ts +++ b/apps/sim/stores/workflows/workflow/store.test.ts @@ -976,6 +976,24 @@ describe('workflow store', () => { }) }) + describe('setBlockErrorEnabled', () => { + it('updates the persisted Error output flag', () => { + addBlock('function-1', 'function', 'Function 1', { x: 0, y: 0 }) + + useWorkflowStore.getState().setBlockErrorEnabled('function-1', true) + + expect(useWorkflowStore.getState().blocks['function-1'].errorEnabled).toBe(true) + }) + + it('ignores an unknown block id', () => { + const before = useWorkflowStore.getState().blocks + + useWorkflowStore.getState().setBlockErrorEnabled('missing', true) + + expect(useWorkflowStore.getState().blocks).toEqual(before) + }) + }) + describe('syncDynamicHandleSubblockValue', () => { it('should sync condition topology values into the workflow store', () => { addBlock('condition-1', 'condition', 'Condition 1', { x: 0, y: 0 }) diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index 11008b1658f..71dbdf41532 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -784,6 +784,25 @@ export const useWorkflowStore = create()( } }, + setBlockErrorEnabled: (id: string, errorEnabled: boolean) => { + set((state) => { + const block = state.blocks[id] + if (!block) return state + return { + blocks: { + ...state.blocks, + [id]: { + ...block, + errorEnabled, + }, + }, + edges: [...state.edges], + loops: { ...state.loops }, + } + }) + get().updateLastSaved() + }, + setBlockAdvancedMode: (id: string, advancedMode: boolean) => { set((state) => ({ blocks: { diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 1afca6311a1..15d1c507aee 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -59,6 +59,7 @@ export interface WorkflowActions { batchToggleHandles: (ids: string[]) => void batchAddEdges: (edges: Edge[], options?: { skipValidation?: boolean }) => void batchRemoveEdges: (ids: string[]) => void + setBlockErrorEnabled: (id: string, errorEnabled: boolean) => void clear: () => Partial updateLastSaved: () => void setBlockEnabled: (id: string, enabled: boolean) => void diff --git a/bun.lock b/bun.lock index 272fa46bc3b..c23cefd5043 100644 --- a/bun.lock +++ b/bun.lock @@ -557,6 +557,7 @@ "@sim/emcn": "workspace:*", "@sim/tsconfig": "workspace:*", "@sim/utils": "workspace:*", + "@sim/workflow-types": "workspace:*", "@types/react": "^19", "lucide-react": "^0.479.0", "react": "19.2.4", @@ -568,6 +569,7 @@ "peerDependencies": { "@sim/emcn": "workspace:*", "@sim/utils": "workspace:*", + "@sim/workflow-types": "workspace:*", "lucide-react": ">=0.479.0", "react": "^19", "reactflow": "^11.11.4", diff --git a/packages/emcn/src/components/chip-tag/chip-tag.tsx b/packages/emcn/src/components/chip-tag/chip-tag.tsx index 32fe4cf022f..f1343b449e6 100644 --- a/packages/emcn/src/components/chip-tag/chip-tag.tsx +++ b/packages/emcn/src/components/chip-tag/chip-tag.tsx @@ -20,6 +20,8 @@ import { cn } from '../../lib/cn' * inverse-surface convention one step softer than near-black. For eyebrow * kickers and emphasis labels that should read as a solid chip rather than a * bordered one. + * - `workflow` — a restrained type cue for workflow cards. Pair with `tone`; + * the icon remains the non-color identifier. * - `invite` — recipient pill used in invite/sharing flows. Borrows the chip * family's icon gap (`gap-1.5`), `--text-body` label, and `--text-icon` * leading/trailing icons; pairs with the `invalid` boolean to flip to an @@ -33,10 +35,20 @@ const chipTagVariants = cva( mono: 'h-5 gap-[3px] px-1 bg-[var(--surface-5)] text-[var(--text-primary)] dark:bg-[var(--surface-4)]', gray: 'h-5 gap-[3px] px-1 bg-[var(--surface-5)] text-[var(--text-secondary)] shadow-[inset_0_0_0_1px_var(--border-1)]', solid: 'h-5 gap-[3px] px-1 bg-[var(--text-secondary)] text-[var(--text-inverse)]', + workflow: 'h-5 gap-[3px] px-1', invite: 'h-5 gap-1.5 px-1 bg-[var(--surface-5)] text-[var(--text-body)] shadow-[inset_0_0_0_1px_var(--border-1)] dark:bg-[var(--surface-4)]', }, invalid: { true: '', false: '' }, + tone: { + neutral: '', + inverse: '', + teal: '', + indigo: '', + indigoStrong: '', + violet: '', + amber: '', + }, }, compoundVariants: [ { @@ -44,8 +56,54 @@ const chipTagVariants = cva( invalid: true, className: 'bg-[var(--badge-error-bg)] text-[var(--text-error)] shadow-none', }, + { + variant: 'workflow', + tone: 'neutral', + className: + 'bg-[oklch(0.91_0.012_230)] text-[oklch(0.29_0.015_230)] dark:bg-[oklch(0.32_0.012_230)] dark:text-[oklch(0.9_0.015_230)]', + }, + { + variant: 'workflow', + tone: 'inverse', + className: + 'bg-[oklch(0.4386_0_0)] text-[oklch(1_0_0)] dark:bg-[oklch(1_0_0)] dark:text-[oklch(0.4386_0_0)]', + }, + { + variant: 'workflow', + tone: 'teal', + className: + 'bg-[oklch(0.91_0.025_190)] text-[oklch(0.3_0.045_190)] dark:bg-[oklch(0.31_0.035_190)] dark:text-[oklch(0.9_0.035_190)]', + }, + { + variant: 'workflow', + tone: 'indigo', + className: + 'bg-[oklch(0.91_0.03_270)] text-[oklch(0.3_0.05_270)] dark:bg-[oklch(0.31_0.04_270)] dark:text-[oklch(0.9_0.035_270)]', + }, + { + variant: 'workflow', + tone: 'indigoStrong', + className: + 'bg-[oklch(0.88_0.065_270)] text-[oklch(0.28_0.075_270)] dark:bg-[oklch(0.33_0.065_270)] dark:text-[oklch(0.92_0.05_270)]', + }, + { + variant: 'workflow', + tone: 'violet', + className: + 'bg-[oklch(0.91_0.028_305)] text-[oklch(0.3_0.045_305)] dark:bg-[oklch(0.31_0.04_305)] dark:text-[oklch(0.9_0.035_305)]', + }, + { + variant: 'workflow', + tone: 'amber', + className: + 'bg-[oklch(0.92_0.032_80)] text-[oklch(0.32_0.055_70)] dark:bg-[oklch(0.32_0.04_80)] dark:text-[oklch(0.91_0.04_80)]', + }, ], - defaultVariants: { variant: 'mono', invalid: false }, + defaultVariants: { + variant: 'mono', + invalid: false, + tone: 'neutral', + }, } ) @@ -94,6 +152,7 @@ export interface ChipTagProps export function ChipTag({ variant, invalid, + tone, className, children, leftIcon: LeftIcon, @@ -107,7 +166,7 @@ export function ChipTag({ const interactive = RightIcon != null && onRightIconClick != null return ( - + {LeftIcon ? : null} {children} {RightIcon ? ( diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index b9b3d37165c..cedc7d4dfa4 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -189,6 +189,7 @@ export { isFocusVisible, isTextClipped, Tooltip, + type UseFloatingTooltipOptions, useFloatingTooltip, useIsOverflowing, } from './tooltip/tooltip' diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index 0c600553338..bf5e59cccdc 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -57,6 +57,14 @@ const HIDDEN_STATE: FloatingTooltipState = { alignY: 'below', } +export interface UseFloatingTooltipOptions { + /** + * Prefer placing the bubble above the cursor. Still flips below when the + * pointer is too close to the top of the viewport. + */ + preferAbove?: boolean +} + /** * Drives a pointer-reactive floating tooltip. `canShow` is queried on every * gesture with the event target, letting the caller gate the tooltip on its own @@ -64,12 +72,17 @@ const HIDDEN_STATE: FloatingTooltipState = { * a {@link FloatingTooltip} and a stable set of {@link FloatingTooltipHandlers} * to spread onto the trigger element. */ -export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): { +export function useFloatingTooltip( + canShow: (target: HTMLElement) => boolean, + options: UseFloatingTooltipOptions = {} +): { state: FloatingTooltipState handlers: FloatingTooltipHandlers } { const canShowRef = React.useRef(canShow) canShowRef.current = canShow + const preferAboveRef = React.useRef(options.preferAbove === true) + preferAboveRef.current = options.preferAbove === true const lastPointerRef = React.useRef(null) const [state, setState] = React.useState(HIDDEN_STATE) @@ -80,11 +93,14 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): { setState((current) => (current.visible ? HIDDEN_STATE : current)) } + const position = (clientX: number, clientY: number) => + getTooltipPosition(clientX, clientY, preferAboveRef.current) + const showStatic = (clientX: number, clientY: number) => { lastPointerRef.current = { x: clientX, y: clientY, time: performance.now() } setState({ visible: true, - ...getTooltipPosition(clientX, clientY), + ...position(clientX, clientY), skew: 0, scaleX: 1, scaleY: 1, @@ -108,7 +124,7 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): { lastPointerRef.current = { x: event.clientX, y: event.clientY, time: now } setState({ visible: true, - ...getTooltipPosition(event.clientX, event.clientY), + ...position(event.clientX, event.clientY), skew: clamp(velocityX * 0.11, -6, 6), scaleX: 1 + Math.min(0.035, velocity / 1100), scaleY: 1 - Math.min(0.02, velocity / 1500), @@ -124,7 +140,9 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): { lastPointerRef.current = null setState({ visible: true, - ...getTooltipPosition(rect.left + rect.width / 2, rect.bottom), + /* Anchor on the edge the bubble grows away from, or a `preferAbove` + tooltip lands inside a trigger taller than the offset. */ + ...position(rect.left + rect.width / 2, preferAboveRef.current ? rect.top : rect.bottom), skew: 0, scaleX: 1, scaleY: 1, @@ -253,7 +271,10 @@ export const FloatingTooltip = React.memo(function FloatingTooltip({ )} style={{ transform: `${getTooltipTranslate(state, offset)} skew(${state.skew}deg) scale(${state.scaleX}, ${state.scaleY})`, - transformOrigin: state.alignX === 'left' ? '12px 12px' : 'calc(100% - 12px) 12px', + transformOrigin: [ + state.alignX === 'left' ? '12px' : 'calc(100% - 12px)', + state.alignY === 'below' ? '12px' : 'calc(100% - 12px)', + ].join(' '), }} > {children ?? {label}} @@ -264,14 +285,17 @@ export const FloatingTooltip = React.memo(function FloatingTooltip({ function getTooltipPosition( clientX: number, - clientY: number + clientY: number, + preferAbove = false ): Pick { if (typeof window === 'undefined') { - return { x: clientX, y: clientY, alignX: 'left', alignY: 'below' } + return { x: clientX, y: clientY, alignX: 'left', alignY: preferAbove ? 'above' : 'below' } } const alignX = window.innerWidth - clientX < EDGE_THRESHOLD ? 'right' : 'left' - const alignY = window.innerHeight - clientY < EDGE_THRESHOLD / 2 ? 'above' : 'below' + const nearTop = clientY < EDGE_THRESHOLD / 2 + const nearBottom = window.innerHeight - clientY < EDGE_THRESHOLD / 2 + const alignY = preferAbove ? (nearTop ? 'below' : 'above') : nearBottom ? 'above' : 'below' return { x: clamp(clientX, EDGE_GUTTER, window.innerWidth - EDGE_GUTTER), @@ -324,6 +348,11 @@ interface RootProps { children: React.ReactNode /** Accepted for API compatibility; the floating tooltip has no hover delay. */ delayDuration?: number + /** + * Prefer the bubble above the cursor (e.g. action bars sitting on top of a card). + * Still flips below when the pointer is too close to the top of the viewport. + */ + preferAbove?: boolean } /** @@ -340,9 +369,9 @@ interface RootProps { * * ``` */ -function Root({ children }: RootProps) { +function Root({ children, preferAbove = false }: RootProps) { const contentId = React.useId() - const { state, handlers } = useFloatingTooltip(ALWAYS_SHOW) + const { state, handlers } = useFloatingTooltip(ALWAYS_SHOW, { preferAbove }) const value = React.useMemo( () => ({ state, handlers, contentId }), [state, handlers, contentId] diff --git a/packages/realtime-protocol/src/constants.ts b/packages/realtime-protocol/src/constants.ts index 37074c5a807..4ab84e87c16 100644 --- a/packages/realtime-protocol/src/constants.ts +++ b/packages/realtime-protocol/src/constants.ts @@ -4,6 +4,7 @@ export const BLOCK_OPERATIONS = { TOGGLE_ENABLED: 'toggle-enabled', UPDATE_PARENT: 'update-parent', UPDATE_ADVANCED_MODE: 'update-advanced-mode', + UPDATE_ERROR_ENABLED: 'update-error-enabled', UPDATE_CANONICAL_MODE: 'update-canonical-mode', REPLACE_CANONICAL_MODES: 'replace-canonical-modes', TOGGLE_HANDLES: 'toggle-handles', diff --git a/packages/realtime-protocol/src/schemas.ts b/packages/realtime-protocol/src/schemas.ts index 3dd8da3bcc0..97c9a7c63ca 100644 --- a/packages/realtime-protocol/src/schemas.ts +++ b/packages/realtime-protocol/src/schemas.ts @@ -32,6 +32,7 @@ export const BlockOperationSchema = z.object({ BLOCK_OPERATIONS.TOGGLE_ENABLED, BLOCK_OPERATIONS.UPDATE_PARENT, BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, + BLOCK_OPERATIONS.UPDATE_ERROR_ENABLED, BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE, BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES, BLOCK_OPERATIONS.TOGGLE_HANDLES, @@ -50,6 +51,7 @@ export const BlockOperationSchema = z.object({ extent: z.enum(['parent']).nullable().optional(), enabled: z.boolean().optional(), advancedMode: z.boolean().optional(), + errorEnabled: z.boolean().optional(), horizontalHandles: z.boolean().optional(), canonicalId: z.string().optional(), canonicalMode: z.enum(['basic', 'advanced']).optional(), diff --git a/packages/testing/src/factories/permission.factory.ts b/packages/testing/src/factories/permission.factory.ts index 78de7f85f8a..e4ce62c951c 100644 --- a/packages/testing/src/factories/permission.factory.ts +++ b/packages/testing/src/factories/permission.factory.ts @@ -260,6 +260,7 @@ const BLOCK_OPERATIONS = { TOGGLE_ENABLED: 'toggle-enabled', UPDATE_PARENT: 'update-parent', UPDATE_ADVANCED_MODE: 'update-advanced-mode', + UPDATE_ERROR_ENABLED: 'update-error-enabled', UPDATE_CANONICAL_MODE: 'update-canonical-mode', TOGGLE_HANDLES: 'toggle-handles', } as const diff --git a/packages/workflow-persistence/src/load.ts b/packages/workflow-persistence/src/load.ts index 6d16e41427a..533fb99dcd4 100644 --- a/packages/workflow-persistence/src/load.ts +++ b/packages/workflow-persistence/src/load.ts @@ -1,7 +1,11 @@ import { db, workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db' import { createLogger } from '@sim/logger' import type { BlockState, Loop, Parallel } from '@sim/workflow-types/workflow' -import { SUBFLOW_TYPES } from '@sim/workflow-types/workflow' +import { + normalizePositionedSourceHandleId, + normalizePositionedTargetHandleId, + SUBFLOW_TYPES, +} from '@sim/workflow-types/workflow' import { and, eq, getTableColumns, isNull, sql } from 'drizzle-orm' import type { Edge } from 'reactflow' import { clampParallelBatchSize } from './subflow-helpers' @@ -82,6 +86,7 @@ export async function loadWorkflowFromNormalizedTablesRaw( enabled: block.enabled, horizontalHandles: block.horizontalHandles, advancedMode: block.advancedMode, + errorEnabled: blockData?.errorEnabled === true, triggerMode: block.triggerMode, height: Number(block.height), subBlocks: (block.subBlocks as BlockState['subBlocks']) || {}, @@ -98,8 +103,8 @@ export async function loadWorkflowFromNormalizedTablesRaw( id: edge.id, source: edge.sourceBlockId, target: edge.targetBlockId, - sourceHandle: edge.sourceHandle ?? undefined, - targetHandle: edge.targetHandle ?? undefined, + sourceHandle: normalizePositionedSourceHandleId(edge.sourceHandle ?? undefined), + targetHandle: normalizePositionedTargetHandleId(edge.targetHandle ?? undefined), type: 'default', data: {}, })) diff --git a/packages/workflow-persistence/src/save.ts b/packages/workflow-persistence/src/save.ts index fefe1d354d6..0093f1f443c 100644 --- a/packages/workflow-persistence/src/save.ts +++ b/packages/workflow-persistence/src/save.ts @@ -2,7 +2,11 @@ import { db, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' -import { SUBFLOW_TYPES } from '@sim/workflow-types/workflow' +import { + normalizePositionedSourceHandleId, + normalizePositionedTargetHandleId, + SUBFLOW_TYPES, +} from '@sim/workflow-types/workflow' import type { InferInsertModel } from 'drizzle-orm' import { eq } from 'drizzle-orm' import { generateLoopBlocks, generateParallelBlocks } from './subflow-helpers' @@ -43,7 +47,10 @@ export async function saveWorkflowToNormalizedTables( height: String(block.height || 0), subBlocks: block.subBlocks || {}, outputs: block.outputs || {}, - data: block.data || {}, + data: { + ...(block.data || {}), + errorEnabled: block.errorEnabled ?? false, + }, parentId: block.data?.parentId || null, extent: block.data?.extent || null, locked: block.locked ?? false, @@ -58,8 +65,8 @@ export async function saveWorkflowToNormalizedTables( workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: normalizePositionedSourceHandleId(edge.sourceHandle || null), + targetHandle: normalizePositionedTargetHandleId(edge.targetHandle || null), })) await tx.insert(workflowEdges).values(edgeInserts) diff --git a/packages/workflow-renderer/package.json b/packages/workflow-renderer/package.json index d31bb33c895..1cc6f951e6a 100644 --- a/packages/workflow-renderer/package.json +++ b/packages/workflow-renderer/package.json @@ -28,6 +28,7 @@ "peerDependencies": { "@sim/emcn": "workspace:*", "@sim/utils": "workspace:*", + "@sim/workflow-types": "workspace:*", "lucide-react": ">=0.479.0", "react": "^19", "reactflow": "^11.11.4", @@ -38,6 +39,7 @@ "@sim/emcn": "workspace:*", "@sim/tsconfig": "workspace:*", "@sim/utils": "workspace:*", + "@sim/workflow-types": "workspace:*", "@types/react": "^19", "lucide-react": "^0.479.0", "react": "19.2.4", diff --git a/packages/workflow-renderer/src/dimensions.ts b/packages/workflow-renderer/src/dimensions.ts index 736e95b7727..0092d5252ae 100644 --- a/packages/workflow-renderer/src/dimensions.ts +++ b/packages/workflow-renderer/src/dimensions.ts @@ -11,8 +11,27 @@ export const BLOCK_DIMENSIONS = { FIXED_WIDTH: 250, HEADER_HEIGHT: 40, MIN_HEIGHT: 100, + /** + * Shortest card the border renderer reliably paints. Below ~48px its + * animation frame yields no path and the card renders bodiless. Header-only + * cards must size their DOM host to this same value — a shorter host with + * `preserveAspectRatio='none'` vertically squashes the action-menu tab. + */ + MIN_PAINTED_HEIGHT: 48, WORKFLOW_CONTENT_PADDING: 16, - WORKFLOW_ROW_HEIGHT: 29, + /** One rendered summary row (text-sm line box). */ + WORKFLOW_ROW_HEIGHT: 20, + /** `gap-2` between sections inside the card's content column. */ + WORKFLOW_CONTENT_GAP: 8, + /** The error-output row: a short gray bar sized to its 20px switch. */ + WORKFLOW_ERROR_ROW_HEIGHT: 24, + /** Chips row: the 20px ChipTag itself — the gap to the next section is + * added by WORKFLOW_CONTENT_GAP, not baked in here. */ + WORKFLOW_CHIPS_ROW_HEIGHT: 20, + /** Natural-language summary line height (text-sm with inline value chips). */ + WORKFLOW_SENTENCE_LINE_HEIGHT: 24, + /** Footer divider above the error row: 1px border + 6px padding */ + WORKFLOW_FOOTER_DIVIDER_HEIGHT: 7, NOTE_CONTENT_PADDING: 14, NOTE_MIN_CONTENT_HEIGHT: 20, NOTE_BASE_CONTENT_HEIGHT: 60, @@ -39,6 +58,8 @@ export const HANDLE_POSITIONS = { DEFAULT_Y_OFFSET: 20, /** Error handle offset from block bottom */ ERROR_BOTTOM_OFFSET: 17, + /** Error knob center inset from the card's right edge. */ + ERROR_RIGHT_OFFSET: 30, /** * Y of the first condition-row handle: 40px header + 1px divider + * 8px content padding + half of the 20px row diff --git a/packages/workflow-renderer/src/edge/workflow-edge-view.tsx b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx index 6256d8f70df..e52c8b461ab 100644 --- a/packages/workflow-renderer/src/edge/workflow-edge-view.tsx +++ b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx @@ -18,6 +18,11 @@ export interface WorkflowEdgeViewProps extends EdgeProps { runStatus: EdgeRunStatus /** Whether `runStatus` came from a preview run (drives success coloring). */ isPreviewRun: boolean + /** + * Whether either endpoint block is selected on the canvas — brightens the + * edge alongside the selected node. Status colors (diff/run/error) win. + */ + isConnectedToSelection?: boolean } /** @@ -44,6 +49,7 @@ export function WorkflowEdgeView({ diffStatus, runStatus, isPreviewRun, + isConnectedToSelection = false, }: WorkflowEdgeViewProps) { const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left' @@ -80,6 +86,9 @@ export function WorkflowEdgeView({ } else if (isErrorEdge) { // Error edges that weren't taken stay red color = 'var(--text-error)' + } else if (isConnectedToSelection) { + // Match the selected block ring / swell (`--text-secondary`) + color = 'var(--text-secondary)' } if (isSelected) { @@ -87,19 +96,14 @@ export function WorkflowEdgeView({ } return { - ...(style ?? {}), - strokeWidth: diffStatus - ? 3 - : runStatus === 'success' || runStatus === 'error' - ? 2.5 - : isSelected - ? 2.5 - : 2, - stroke: color, + strokeWidth: diffStatus ? 2.5 : runStatus === 'success' || runStatus === 'error' ? 2 : 1.5, strokeDasharray: diffStatus === 'deleted' ? '10,5' : undefined, opacity, + ...(style ?? {}), + // Selection/status stroke must win over any default edge style. + stroke: color, } - }, [style, diffStatus, isSelected, isErrorEdge, runStatus, isPreviewRun]) + }, [style, diffStatus, isSelected, isErrorEdge, runStatus, isPreviewRun, isConnectedToSelection]) return ( <> diff --git a/packages/workflow-renderer/src/index.ts b/packages/workflow-renderer/src/index.ts index 0c6938dc112..9e462e430e9 100644 --- a/packages/workflow-renderer/src/index.ts +++ b/packages/workflow-renderer/src/index.ts @@ -1,5 +1,6 @@ export * from './dimensions' export { WorkflowEdgeView, type WorkflowEdgeViewProps } from './edge/workflow-edge-view' +export { humanizeBlockName } from './lib/humanize-block-name' export { NoteBlockView, type NoteBlockViewProps } from './note/note-block-view' export { type SubflowNodeData, @@ -7,8 +8,29 @@ export { type SubflowNodeViewProps, } from './subflow/subflow-node-view' export type { BlockRunStatus, DiffStatus, EdgeDiffStatus, EdgeRunStatus } from './types' +export { + CURSOR_SOURCE_HANDLE_ID, + getCursorBranchSourceHandleId, + getCursorSourceHandleId, + getCursorSourceHandlePosition, + normalizeCursorSourceHandleId, +} from './workflow-block/source-handle' export { SubBlockRowView, type SubBlockRowViewProps } from './workflow-block/sub-block-row-view' export { + CONNECTION_KNOB_PEAK_PX, + CURSOR_SWELL_LENGTH_PX, + getWorkflowBorderFrameDeltaSeconds, + isActionMenuSwellReady, + WorkflowBlockBorder, + type WorkflowBorderCursorHandle, + type WorkflowBorderPort, +} from './workflow-block/workflow-block-border' +export { + ERROR_SOURCE_HANDLE_POSITION, + getErrorBorderPort, + getErrorSourceHandleStyle, + getNearestBranchCursorHandleId, + getWorkflowTypeAccent, WorkflowBlockView, type WorkflowBlockViewProps, } from './workflow-block/workflow-block-view' diff --git a/packages/workflow-renderer/src/lib/humanize-block-name.ts b/packages/workflow-renderer/src/lib/humanize-block-name.ts new file mode 100644 index 00000000000..31e83b6b269 --- /dev/null +++ b/packages/workflow-renderer/src/lib/humanize-block-name.ts @@ -0,0 +1,20 @@ +/** + * Humanizes a block's technical name for the card title: camelCase, + * snake_case, and kebab-case become spaced Title Case ("updatePosted" → + * "Update Posted", "did_it_post" → "Did It Post"). Existing capitals and + * acronym runs are preserved ("APICall" → "API Call"); names that are + * already natural language pass through unchanged. + */ +export function humanizeBlockName(name: string): string { + const spaced = name + .replace(/[_-]+/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/\s+/g, ' ') + .trim() + if (!spaced) return name + return spaced + .split(' ') + .map((word) => (word ? word.charAt(0).toUpperCase() + word.slice(1) : word)) + .join(' ') +} diff --git a/packages/workflow-renderer/src/workflow-block/source-handle.ts b/packages/workflow-renderer/src/workflow-block/source-handle.ts new file mode 100644 index 00000000000..af8d74fa6d0 --- /dev/null +++ b/packages/workflow-renderer/src/workflow-block/source-handle.ts @@ -0,0 +1,44 @@ +import { + getPositionedSourceHandleId, + type PositionedSourceHandleSide, + type WorkflowCardSide, +} from '@sim/workflow-types/workflow' +import { Position } from 'reactflow' + +export const CURSOR_SOURCE_HANDLE_ID = 'source-cursor' +const CURSOR_BRANCH_SOURCE_HANDLE_PREFIX = `${CURSOR_SOURCE_HANDLE_ID}-branch-` + +/** Returns the temporary React Flow handle ID under the cursor swell. */ +export function getCursorSourceHandleId(side: PositionedSourceHandleSide): string { + return `${CURSOR_SOURCE_HANDLE_ID}-${side}` +} + +/** Keeps a branch output distinct while its temporary handle follows the cursor swell. */ +export function getCursorBranchSourceHandleId(branchHandleId: string): string { + return `${CURSOR_BRANCH_SOURCE_HANDLE_PREFIX}${branchHandleId}` +} + +/** Uses the physical swell edge for the temporary preview's exit direction. */ +export function getCursorSourceHandlePosition(side: WorkflowCardSide): Position { + if (side === 'top') return Position.Top + if (side === 'bottom') return Position.Bottom + if (side === 'left') return Position.Left + return Position.Right +} + +/** Converts a temporary cursor handle into its persistent side anchor. */ +export function normalizeCursorSourceHandleId( + handleId: string | null | undefined +): string | null | undefined { + if (handleId?.startsWith(CURSOR_BRANCH_SOURCE_HANDLE_PREFIX)) { + return handleId.slice(CURSOR_BRANCH_SOURCE_HANDLE_PREFIX.length) + } + + const prefix = `${CURSOR_SOURCE_HANDLE_ID}-` + if (!handleId?.startsWith(prefix)) return handleId + + const side = handleId.slice(prefix.length) + if (side === 'left' || side === 'right') return getPositionedSourceHandleId(side) + if (side === 'top' || side === 'bottom') return getPositionedSourceHandleId('right') + return handleId +} diff --git a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx index 87c4ac824b4..3cdd3ace607 100644 --- a/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/sub-block-row-view.tsx @@ -1,4 +1,5 @@ -import { cn } from '@sim/emcn' +import type { ComponentType } from 'react' +import { ChipTag, cn } from '@sim/emcn' import { OverflowSpan } from '../lib/overflow-span' /** @@ -8,24 +9,99 @@ import { OverflowSpan } from '../lib/overflow-span' * strings, so this view carries no store, query, or registry coupling. */ export interface SubBlockRowViewProps { - /** Subblock label, rendered capitalized on the left. */ + /** Subblock label, rendered capitalized on the left (row variant). */ 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 + /** + * Leading icon for the `meta` variant; without one the variant falls back + * to the labeled `row` presentation. + */ + icon?: ComponentType<{ className?: string }> + /** + * Presentation variant: + * - `row` (default): label/value summary line (hover drawer, fallbacks) + * - `meta`: icon + value line — the collapsed card's compact fact row + * - `statement-primary`: strong inline fragment (the operation) + * - `statement-muted`: muted inline fragment (the target / model) + * - `inline-value`: small value chip embedded in a summary sentence + * - `footer`: muted footer label (the error-port row) + */ + variant?: 'row' | 'meta' | 'statement-primary' | 'statement-muted' | 'inline-value' | 'footer' } /** - * Pure renderer for a collapsed block's subblock summary row: a capitalized - * title and its resolved display value. + * Pure renderer for a collapsed block's subblock summary: a label/value row, + * an icon fact row, an inline statement fragment, or the footer error label. * * The fixed `h-5` row height is part of the handle-position contract — * `HANDLE_POSITIONS.CONDITION_ROW_HEIGHT` in dimensions.ts assumes a 20px row * plus the container's 8px gap, so condition/router source handles align with * their rows. */ -export function SubBlockRowView({ title, displayValue, isMonospace }: SubBlockRowViewProps) { +export function SubBlockRowView({ + title, + displayValue, + isMonospace, + icon: Icon, + variant = 'row', +}: SubBlockRowViewProps) { + if (variant === 'inline-value') { + return ( + + + + ) + } + + if (variant === 'statement-primary' || variant === 'statement-muted') { + return ( + + ) + } + + if (variant === 'meta' && Icon) { + return ( +
+ + +
+ ) + } + + if (variant === 'footer') { + return ( +
+ +
+ ) + } + return (
{ + if (!Number.isFinite(timestamp)) return 0 + if (previousTimestamp === 0) return 1 / 60 + const elapsed = (timestamp - previousTimestamp) / 1000 + if (!Number.isFinite(elapsed)) return 0 + return Math.min(MAX_FRAME_DELTA_SECONDS, Math.max(0, elapsed)) +} + +export const isActionMenuSwellReady = (target: number, value: number) => + target === 1 && value >= ACTION_MENU_CONTENT_READY_THRESHOLD + +export interface WorkflowBorderPort { + id: string + side: WorkflowCardSide + position: number | 'center' | { fromEnd: number } + plateau: number + color?: string + restAmplitude?: number + hoverAmplitude?: number + magnetizable?: boolean + revealOnCardHover?: boolean +} + +export interface WorkflowBorderCursorHandle { + side: PositionedSourceHandleSide + edgeSide: WorkflowCardSide + x: number + y: number +} + +interface WorkflowBlockBorderProps { + nodeId?: string + getConnectionNodeId?: () => string | null + ports: WorkflowBorderPort[] + radius?: number + hasRing: boolean + ringStyles: string + /** + * Card height in layout px. Must match what the card actually renders — the + * silhouette is drawn from it, so an over-estimate paints a card taller than + * its content. Omit to measure the host instead (read-only surfaces). + */ + height?: number + onCursorHandleChange?: (handle: WorkflowBorderCursorHandle | null) => void + onActionMenuReadyChange?: (ready: boolean) => void +} + +interface PerimeterPoint { + s: number + x: number + y: number + nx: number + ny: number + tx: number + ty: number +} + +interface PerimeterSegment { + kind: 'line' | 'arc' + startS: number + length: number + radius?: number + pointAt: (distance: number) => Omit +} + +interface PerimeterGeometry { + samples: PerimeterPoint[] + segments: PerimeterSegment[] + length: number +} + +interface PortSample extends WorkflowBorderPort { + s: number +} + +interface SpringValue { + value: number + velocity: number + target: number +} + +interface BulgeFeature { + center: number + plateau: number + peak: number + shoulder: number + falloff?: 'action-menu' +} + +interface ActiveInterval { + start: number + end: number +} + +const isPrimaryConnectionPort = (portId: string) => + portId === 'source' || portId === 'target' || isPositionedSourceHandle(portId) + +/** + * Every connection knob is the same swell, scaled by its own tab length — + * branch rows and the error output are the main port's shape at ~63%, not a + * different nub. The primary ports measure against the cursor footprint so a + * resting knob and the hover swell are interchangeable silhouettes. + */ +const getPortBulgeProfile = (port: WorkflowBorderPort) => { + if (port.id === 'action-menu') { + return { + plateau: Math.max(0, port.plateau - ACTION_MENU_TAPER_PX), + shoulder: ACTION_MENU_SHOULDER_PX, + } + } + const footprint = isPrimaryConnectionPort(port.id) ? CURSOR_SWELL_LENGTH_PX : port.plateau + return { + plateau: footprint * CONNECTION_PLATEAU_RATIO, + shoulder: footprint * CONNECTION_SHOULDER_RATIO, + } +} + +/** + * Every connection knob protrudes the same distance — only its width scales. + * Edges anchor a fixed `CONNECTION_KNOB_PEAK_PX` outside the card, so a knob + * that peaked any lower would leave the line floating short of it. + * + * The action menu is not a connection knob: nothing anchors to it, and its own + * amplitude multiplies this base, so it keeps the shallower peak that sets the + * tab's height against the card. + */ +const getPortPeak = (port: WorkflowBorderPort) => + port.id === 'action-menu' ? ACTION_MENU_PEAK_PX : CONNECTION_KNOB_PEAK_PX + +const smootherstep = (value: number) => { + const clamped = Math.min(1, Math.max(0, value)) + return clamped * clamped * clamped * (clamped * (clamped * 6 - 15) + 10) +} + +const cubicBezierCoordinate = ( + t: number, + start: number, + firstControl: number, + secondControl: number, + end: number +) => { + const inverse = 1 - t + return ( + inverse * inverse * inverse * start + + 3 * inverse * inverse * t * firstControl + + 3 * inverse * t * t * secondControl + + t * t * t * end + ) +} + +const actionMenuCurveCoordinate = (parameter: number, axis: 'x' | 'y') => { + if (parameter <= 1) { + return axis === 'x' + ? cubicBezierCoordinate( + parameter, + 0, + ACTION_MENU_CURVE_TOP_X1, + ACTION_MENU_CURVE_TOP_X2, + ACTION_MENU_CURVE_MID_X + ) + : cubicBezierCoordinate(parameter, 0, 0, 0, ACTION_MENU_CURVE_MID_Y) + } + const t = parameter - 1 + return axis === 'x' + ? cubicBezierCoordinate( + t, + ACTION_MENU_CURVE_MID_X, + ACTION_MENU_CURVE_BOTTOM_X1, + ACTION_MENU_CURVE_BOTTOM_X2, + 1 + ) + : cubicBezierCoordinate( + t, + ACTION_MENU_CURVE_MID_Y, + ACTION_MENU_CURVE_BOTTOM_Y1, + ACTION_MENU_CURVE_BOTTOM_Y2, + 1 + ) +} + +const actionMenuFalloff = (value: number) => { + const clamped = Math.min(1, Math.max(0, value)) + let lower = 0 + let upper = 2 + for (let index = 0; index < 12; index++) { + const midpoint = (lower + upper) / 2 + const x = actionMenuCurveCoordinate(midpoint, 'x') + if (x < clamped) lower = midpoint + else upper = midpoint + } + return actionMenuCurveCoordinate((lower + upper) / 2, 'y') +} + +const modulo = (value: number, length: number) => ((value % length) + length) % length + +const shortestArcDelta = (from: number, to: number, length: number) => { + const direct = modulo(to - from, length) + return direct > length / 2 ? direct - length : direct +} + +const arcDistance = (a: number, b: number, length: number) => + Math.abs(shortestArcDelta(a, b, length)) + +const springStep = ( + spring: SpringValue, + deltaSeconds: number, + stiffness = SPRING_STIFFNESS, + damping = SPRING_DAMPING, + bounds?: { min: number; max: number } +) => { + const minimum = bounds?.min ?? Number.NEGATIVE_INFINITY + const maximum = bounds?.max ?? Number.POSITIVE_INFINITY + const target = Number.isFinite(spring.target) + ? Math.min(maximum, Math.max(minimum, spring.target)) + : Math.min(maximum, Math.max(minimum, 0)) + spring.target = target + + if (!Number.isFinite(spring.value) || !Number.isFinite(spring.velocity)) { + spring.value = target + spring.velocity = 0 + return + } + + const safeDelta = Number.isFinite(deltaSeconds) + ? Math.min(MAX_FRAME_DELTA_SECONDS, Math.max(0, deltaSeconds)) + : 0 + const force = stiffness * (target - spring.value) - damping * spring.velocity + const nextVelocity = spring.velocity + force * safeDelta + const nextValue = spring.value + nextVelocity * safeDelta + if (!Number.isFinite(nextValue) || !Number.isFinite(nextVelocity)) { + spring.value = target + spring.velocity = 0 + return + } + + spring.value = Math.min(maximum, Math.max(minimum, nextValue)) + spring.velocity = + (spring.value === minimum && nextVelocity < 0) || (spring.value === maximum && nextVelocity > 0) + ? 0 + : nextVelocity +} + +const springAtRest = (spring: SpringValue) => + Math.abs(spring.target - spring.value) < 0.001 && Math.abs(spring.velocity) < 0.001 + +const bulgeContribution = ( + distance: number, + plateau: number, + peak: number, + shoulderOverride?: number, + falloff?: BulgeFeature['falloff'] +) => { + const plateauHalf = plateau / 2 + if (distance <= plateauHalf) return peak + const shoulder = shoulderOverride ?? Math.max(8, plateau * 0.75) + const footprintHalf = plateauHalf + shoulder + if (distance >= footprintHalf) return 0 + const progress = (distance - plateauHalf) / shoulder + const easedProgress = + falloff === 'action-menu' ? actionMenuFalloff(progress) : smootherstep(progress) + const contribution = peak * (1 - easedProgress) + return contribution < BULGE_VISIBLE_THRESHOLD_PX ? 0 : contribution +} + +const buildRoundedRectPerimeter = (width: number, height: number, requestedRadius: number) => { + const radius = Math.min(requestedRadius, width / 2, height / 2) + const horizontal = Math.max(0, width - radius * 2) + const vertical = Math.max(0, height - radius * 2) + const quarterArc = (Math.PI * radius) / 2 + const segments: PerimeterSegment[] = [] + let s = 0 + + const addLine = ( + length: number, + startX: number, + startY: number, + tx: number, + ty: number, + nx: number, + ny: number + ) => { + segments.push({ + kind: 'line', + startS: s, + length, + pointAt: (distance) => ({ + x: startX + tx * distance, + y: startY + ty * distance, + nx, + ny, + tx, + ty, + }), + }) + s += length + } + + const addArc = (centerX: number, centerY: number, startAngle: number) => { + segments.push({ + kind: 'arc', + startS: s, + length: quarterArc, + radius, + pointAt: (distance) => { + const angle = startAngle + distance / radius + return { + x: centerX + Math.cos(angle) * radius, + y: centerY + Math.sin(angle) * radius, + nx: Math.cos(angle), + ny: Math.sin(angle), + tx: -Math.sin(angle), + ty: Math.cos(angle), + } + }, + }) + s += quarterArc + } + + addLine(horizontal, radius, 0, 1, 0, 0, -1) + addArc(width - radius, radius, -Math.PI / 2) + addLine(vertical, width, radius, 0, 1, 1, 0) + addArc(width - radius, height - radius, 0) + addLine(horizontal, width - radius, height, -1, 0, 0, 1) + addArc(radius, height - radius, Math.PI / 2) + addLine(vertical, 0, height - radius, 0, -1, -1, 0) + addArc(radius, radius, Math.PI) + + const samples: PerimeterPoint[] = [] + for (const segment of segments) { + const count = Math.max(1, Math.ceil(segment.length / SAMPLE_SPACING_PX)) + for (let index = 0; index < count; index++) { + const distance = (index / count) * segment.length + samples.push({ s: segment.startS + distance, ...segment.pointAt(distance) }) + } + } + return { samples, segments, length: s } +} + +const closestSample = ( + samples: PerimeterPoint[], + x: number, + y: number, + predicate?: (sample: PerimeterPoint) => boolean +) => { + let closest = samples[0] + let closestDistance = Number.POSITIVE_INFINITY + for (const sample of samples) { + if (predicate && !predicate(sample)) continue + const distance = (sample.x - x) ** 2 + (sample.y - y) ** 2 + if (distance < closestDistance) { + closest = sample + closestDistance = distance + } + } + return { sample: closest, distance: Math.sqrt(closestDistance) } +} + +const resolvePortSamples = ( + ports: WorkflowBorderPort[], + samples: PerimeterPoint[], + width: number, + height: number +): PortSample[] => + ports.map((port) => { + const axisLength = port.side === 'left' || port.side === 'right' ? height : width + const coordinate = + port.position === 'center' + ? axisLength / 2 + : typeof port.position === 'number' + ? port.position + : axisLength - port.position.fromEnd + const target = + port.side === 'left' + ? { x: 0, y: coordinate } + : port.side === 'right' + ? { x: width, y: coordinate } + : port.side === 'top' + ? { x: coordinate, y: 0 } + : { x: coordinate, y: height } + const normal = + port.side === 'left' + ? { nx: -1, ny: 0 } + : port.side === 'right' + ? { nx: 1, ny: 0 } + : port.side === 'top' + ? { nx: 0, ny: -1 } + : { nx: 0, ny: 1 } + const { sample } = closestSample( + samples, + target.x, + target.y, + (candidate) => candidate.nx === normal.nx && candidate.ny === normal.ny + ) + return { ...port, s: sample.s } + }) + +const pointAtArcLength = (geometry: PerimeterGeometry, arcLength: number) => { + const normalized = modulo(arcLength, geometry.length) + const segment = + geometry.segments.find( + (candidate) => + normalized >= candidate.startS && normalized < candidate.startS + candidate.length + ) ?? geometry.segments[geometry.segments.length - 1] + return { + segment, + local: normalized - segment.startS, + point: segment.pointAt(normalized - segment.startS), + } +} + +const buildActiveIntervals = (features: BulgeFeature[], perimeterLength: number) => { + const split: ActiveInterval[] = [] + for (const feature of features) { + if ( + !Number.isFinite(feature.center) || + !Number.isFinite(feature.plateau) || + !Number.isFinite(feature.peak) || + !Number.isFinite(feature.shoulder) || + feature.peak < 0.25 + ) { + continue + } + const half = feature.plateau / 2 + feature.shoulder + 4 + const start = feature.center - half + const end = feature.center + half + if (start < 0) { + split.push({ start: 0, end }) + split.push({ start: perimeterLength + start, end: perimeterLength }) + } else if (end > perimeterLength) { + split.push({ start, end: perimeterLength }) + split.push({ start: 0, end: end - perimeterLength }) + } else { + split.push({ start, end }) + } + } + split.sort((a, b) => a.start - b.start) + const merged: ActiveInterval[] = [] + for (const interval of split) { + const previous = merged[merged.length - 1] + if (previous && interval.start <= previous.end) { + previous.end = Math.max(previous.end, interval.end) + } else { + merged.push({ ...interval }) + } + } + return merged +} + +const findQuietStart = (intervals: ActiveInterval[], perimeterLength: number) => { + if (intervals.length === 0) return 0 + let largestGap = -1 + let start = 0 + for (let index = 0; index < intervals.length; index++) { + const current = intervals[index] + const next = intervals[(index + 1) % intervals.length] + const nextStart = index === intervals.length - 1 ? next.start + perimeterLength : next.start + const gap = nextStart - current.end + if (gap > largestGap) { + largestGap = gap + start = modulo(current.end + gap / 2, perimeterLength) + } + } + return largestGap > 0 ? start : 0 +} + +const relativeIntervals = (features: BulgeFeature[], startS: number, perimeterLength: number) => { + const intervals = features + .filter( + (feature) => + Number.isFinite(feature.center) && + Number.isFinite(feature.plateau) && + Number.isFinite(feature.peak) && + Number.isFinite(feature.shoulder) && + feature.peak >= 0.25 + ) + .map((feature) => { + const center = modulo(feature.center - startS, perimeterLength) + const half = feature.plateau / 2 + feature.shoulder + 4 + return { start: center - half, end: center + half } + }) + .filter((interval) => interval.end > 0 && interval.start < perimeterLength) + .map((interval) => ({ + start: Math.max(0, interval.start), + end: Math.min(perimeterLength, interval.end), + })) + .sort((a, b) => a.start - b.start) + const merged: ActiveInterval[] = [] + for (const interval of intervals) { + const previous = merged[merged.length - 1] + if (previous && interval.start <= previous.end) { + previous.end = Math.max(previous.end, interval.end) + } else { + merged.push({ ...interval }) + } + } + return merged +} + +const displacementAt = ( + s: number, + features: BulgeFeature[], + perimeterLength: number, + maximum: number +) => { + /* + * Union, not sum. Two overlapping bulges of the same height used to add + * where they crossed, throwing up a spike near twice their peak — the + * pointed mountain seen while the cursor swell slides into an existing + * knob. Taking the max lets them merge into one shape at a constant height. + */ + let displacement = 0 + for (const feature of features) { + const contribution = bulgeContribution( + arcDistance(s, feature.center, perimeterLength), + feature.plateau, + feature.peak, + feature.shoulder, + feature.falloff + ) + if (Number.isFinite(contribution)) { + displacement = Math.max(displacement, contribution) + } + } + const safeMaximum = Number.isFinite(maximum) + ? Math.min(MAX_SILHOUETTE_OUTSET_PX, Math.max(0, maximum)) + : 0 + const clamped = Math.min(displacement, safeMaximum) + return clamped < BULGE_VISIBLE_THRESHOLD_PX ? 0 : clamped +} + +/** + * Where a bulge stops being drawn, by inverting the shoulder's easing at the + * visibility threshold. The mathematical footprint (`plateau/2 + shoulder`) + * overshoots this, because the tail is cut off once it flattens out. + */ +const visibleBulgeHalf = (plateau: number, shoulder: number, peak: number) => { + const plateauHalf = plateau / 2 + if (peak <= BULGE_VISIBLE_THRESHOLD_PX || shoulder <= 0) return plateauHalf + const target = 1 - BULGE_VISIBLE_THRESHOLD_PX / peak + let low = 0 + let high = 1 + for (let step = 0; step < 24; step++) { + const mid = (low + high) / 2 + if (smootherstep(mid) < target) low = mid + else high = mid + } + return plateauHalf + high * shoulder +} + +const appendExactInterval = ( + commands: string[], + geometry: PerimeterGeometry, + startS: number, + from: number, + to: number +) => { + let cursor = from + while (cursor < to - 0.0001) { + const located = pointAtArcLength(geometry, startS + cursor) + const available = located.segment.length - located.local + const step = Math.min(to - cursor, available) + const end = located.segment.pointAt(located.local + step) + commands.push( + located.segment.kind === 'line' + ? `L${end.x.toFixed(2)} ${end.y.toFixed(2)}` + : `A${located.segment.radius} ${located.segment.radius} 0 0 1 ${end.x.toFixed(2)} ${end.y.toFixed(2)}` + ) + cursor += step + } +} + +const appendActiveInterval = ( + commands: string[], + geometry: PerimeterGeometry, + features: BulgeFeature[], + maximum: number, + startS: number, + interval: ActiveInterval +) => { + const length = interval.end - interval.start + const count = Math.max(2, Math.ceil(length / SAMPLE_SPACING_PX)) + const points = Array.from({ length: count + 1 }, (_, index) => { + const relative = interval.start + (index / count) * length + const located = pointAtArcLength(geometry, startS + relative) + const displacement = displacementAt( + modulo(startS + relative, geometry.length), + features, + geometry.length, + maximum + ) + return { + x: located.point.x + located.point.nx * displacement, + y: located.point.y + located.point.ny * displacement, + tx: located.point.tx, + ty: located.point.ty, + } + }) + for (let index = 0; index < points.length - 1; index++) { + const previous = points[Math.max(0, index - 1)] + const current = points[index] + const next = points[index + 1] + const after = points[Math.min(points.length - 1, index + 2)] + const chord = Math.hypot(next.x - current.x, next.y - current.y) + const control1 = + index === 0 + ? { x: current.x + (current.tx * chord) / 3, y: current.y + (current.ty * chord) / 3 } + : { + x: current.x + (next.x - previous.x) / 6, + y: current.y + (next.y - previous.y) / 6, + } + const control2 = + index === points.length - 2 + ? { x: next.x - (next.tx * chord) / 3, y: next.y - (next.ty * chord) / 3 } + : { + x: next.x - (after.x - current.x) / 6, + y: next.y - (after.y - current.y) / 6, + } + commands.push( + `C${control1.x.toFixed(2)} ${control1.y.toFixed(2)} ${control2.x.toFixed(2)} ${control2.y.toFixed(2)} ${next.x.toFixed(2)} ${next.y.toFixed(2)}` + ) + } +} + +/** + * An open path over one span of the outline, sampled and displaced exactly like + * the silhouette is. + * + * A coloured knob used to be a dash on the full perimeter, offset by the port's + * resting arc position. But displacing the outline makes it *longer* than the + * resting perimeter, and the extra length sits entirely at the bulges — so + * normalising with `pathLength` spread that error evenly and the band drifted + * off its own knob, leaving a crescent of base colour showing inside it. Giving + * the knob its own path removes the arc-length bookkeeping altogether: the + * colour is drawn on the same points the silhouette was. + */ +const buildSpanPath = ( + geometry: PerimeterGeometry, + features: BulgeFeature[], + maximum: number, + fromS: number, + toS: number +) => { + const length = toS - fromS + if (length <= 0) return '' + const located = pointAtArcLength(geometry, fromS) + const displacement = displacementAt( + modulo(fromS, geometry.length), + features, + geometry.length, + maximum + ) + const originX = located.point.x + located.point.nx * displacement + const originY = located.point.y + located.point.ny * displacement + const commands = [`M${originX.toFixed(2)} ${originY.toFixed(2)}`] + appendActiveInterval(commands, geometry, features, maximum, fromS, { start: 0, end: length }) + return commands.join(' ') +} + +const buildPiecewisePath = ( + geometry: PerimeterGeometry, + features: BulgeFeature[], + maximum: number +) => { + const circularIntervals = buildActiveIntervals(features, geometry.length) + const startS = findQuietStart(circularIntervals, geometry.length) + const intervals = relativeIntervals(features, startS, geometry.length) + const start = pointAtArcLength(geometry, startS).point + const commands = [`M${start.x.toFixed(2)} ${start.y.toFixed(2)}`] + let cursor = 0 + for (const interval of intervals) { + appendExactInterval(commands, geometry, startS, cursor, interval.start) + appendActiveInterval(commands, geometry, features, maximum, startS, interval) + cursor = interval.end + } + appendExactInterval(commands, geometry, startS, cursor, geometry.length) + commands.push('Z') + return { d: commands.join(' '), startS } +} + +const resolveRing = (ringStyles: string) => { + const color = ringStyles.includes('--border-success') + ? 'var(--border-success)' + : ringStyles.includes('--text-secondary') + ? 'var(--text-secondary)' + : ringStyles.includes('--warning') + ? 'var(--warning)' + : ringStyles.includes('--text-error') + ? 'var(--text-error)' + : ringStyles.includes('--brand-accent') + ? 'var(--brand-accent)' + : 'var(--border-1)' + return { + color, + width: ringStyles.includes('3.5px') ? 7 : 4.5, + pulse: ringStyles.includes('animate-ring-pulse'), + solid: ringStyles.includes('--text-secondary'), + } +} + +export function WorkflowBlockBorder({ + nodeId, + getConnectionNodeId, + ports, + radius = 16, + hasRing, + ringStyles, + height, + onCursorHandleChange, + onActionMenuReadyChange, +}: WorkflowBlockBorderProps) { + const clipId = `workflow-border-${useId().replaceAll(':', '')}` + const svgRef = useRef(null) + const [size, setSize] = useState({ width: 250, height: height ?? 100 }) + const [renderedPath, setRenderedPath] = useState<{ + d: string + startS: number + knobs: Array<{ id: string; color: string; d: string }> + knobKey: string + /** Open span over the action-menu swell, repainted over the knobs. */ + overlay: string + }>({ d: '', startS: 0, knobs: [], knobKey: '', overlay: '' }) + /** + * Render-visible copy of the resolved ports and perimeter length. The + * animation loop reads geometry from `geometryRef`, but the colored port + * segments are painted during render — reading the ref there would strand + * color changes on blocks whose path never moves (a neighbour highlighting + * because its edge got selected), since `setRenderedPath` bails on an + * unchanged path and no re-render would follow. + */ + const frameRef = useRef(null) + const lastFrameRef = useRef(0) + const hoveredPortRef = useRef(null) + const draggedPortRef = useRef(null) + /** Whether the pointer is on an edge at all; the frame decides the rest. */ + const cursorHoverAllowedRef = useRef(false) + /** True while the swell is deswelling at a zone boundary to cross behind. */ + const zoneCrossingRef = useRef(false) + const hoverSpringRef = useRef({ value: 0, velocity: 0, target: 0 }) + const actionMenuSpringRef = useRef({ value: 0, velocity: 0, target: 0 }) + const cursorAmplitudeRef = useRef({ value: 0, velocity: 0, target: 0 }) + const cursorSRef = useRef({ value: 0, velocity: 0, target: 0 }) + const startAnimationRef = useRef<() => void>(() => {}) + /** Synchronous single-frame repaint, for geometry changes that must not lag. */ + const renderNowRef = useRef<() => void>(() => {}) + const updatePointerTargetRef = useRef<(x: number, y: number) => void>(() => {}) + const resetPointerTrackingRef = useRef<() => void>(() => {}) + const onCursorHandleChangeRef = useRef(onCursorHandleChange) + const onActionMenuReadyChangeRef = useRef(onActionMenuReadyChange) + const actionMenuReadyRef = useRef(false) + const sizeRef = useRef(size) + const geometryRef = useRef< + PerimeterGeometry & { + ports: PortSample[] + } + >({ samples: [], segments: [], ports: [], length: 1 }) + sizeRef.current = size + onCursorHandleChangeRef.current = onCursorHandleChange + onActionMenuReadyChangeRef.current = onActionMenuReadyChange + + useLayoutEffect(() => { + if (height !== undefined) { + if (!Number.isFinite(height) || height <= 0) return + setSize((current) => + current.width === 250 && Math.abs(current.height - height) < 0.5 + ? current + : { width: 250, height } + ) + return + } + const host = svgRef.current?.parentElement + if (!host) return + const update = () => { + /* offsetWidth/Height, not getBoundingClientRect: the card sits inside + the canvas' zoom transform and a scaled rect would drift the geometry. */ + const width = host.offsetWidth + const nextHeight = host.offsetHeight + if (Number.isFinite(width) && Number.isFinite(nextHeight) && width > 0 && nextHeight > 0) { + setSize((current) => + current.width === width && current.height === nextHeight + ? current + : { width, height: nextHeight } + ) + } + } + update() + const observer = new ResizeObserver(update) + observer.observe(host) + return () => observer.disconnect() + }, [height]) + + useLayoutEffect(() => { + const perimeter = buildRoundedRectPerimeter(size.width, size.height, radius) + const resolvedPorts = resolvePortSamples(ports, perimeter.samples, size.width, size.height) + geometryRef.current = { + samples: perimeter.samples, + segments: perimeter.segments, + ports: resolvedPorts, + length: perimeter.length, + } + const actionMenuPort = resolvedPorts.find((port) => port.id === 'action-menu') + actionMenuSpringRef.current.target = (actionMenuPort?.restAmplitude ?? 0) > 0 ? 1 : 0 + /* Repaint in the same commit: ports or size changed, and a silhouette + that lags the content by an animation frame reads as a flash. */ + renderNowRef.current() + }, [ports, radius, size.height, size.width]) + + useLayoutEffect(() => { + const svg = svgRef.current + const host = svg?.parentElement + if (!svg || !host) return + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + + /* + * One frame in flight at a time, tracked in frameRef. If a frame dies — + * an exception, or a stale handle left by an HMR remount — frameRef must + * be released, or startAnimation() treats the dead frame as pending and + * the card never paints again. The wrapper guarantees the loop can always + * restart from the next pointer event. + */ + const scheduleFrame = () => { + if (frameRef.current === null) { + frameRef.current = requestAnimationFrame(render) + } + } + + const render = (timestamp: number) => { + frameRef.current = null + try { + renderFrame(timestamp) + } catch (error) { + frameRef.current = null + lastFrameRef.current = 0 + throw error + } + } + + const renderFrame = (timestamp: number) => { + const geometry = geometryRef.current + if (geometry.samples.length === 0) { + onCursorHandleChangeRef.current?.(null) + frameRef.current = null + return + } + const deltaSeconds = getWorkflowBorderFrameDeltaSeconds(timestamp, lastFrameRef.current) + if (Number.isFinite(timestamp) && timestamp > lastFrameRef.current) { + lastFrameRef.current = timestamp + } + + const hoverSpring = hoverSpringRef.current + const actionMenuSpring = actionMenuSpringRef.current + const cursorAmplitude = cursorAmplitudeRef.current + const cursorS = cursorSRef.current + const actionMenuPort = geometry.ports.find((port) => port.id === 'action-menu') + actionMenuSpring.target = (actionMenuPort?.restAmplitude ?? 0) > 0 ? 1 : 0 + /* + * Two different colours must never share one silhouette. The swell is the + * base colour, a connected or error knob is painted over it — a merged + * shape would be part knob colour and part gray, reading as the colours + * bleeding into each other. Same-colour knobs are exempt: gray merging + * into gray is the gooey join, not a blend. + * + * Fading alone cannot guarantee that — the amplitude spring takes real + * time to decay, and during those frames the still-visible swell keeps + * travelling and sweeps straight across the knob. So the exclusion is + * structural: while the swell is meaningfully visible its position is + * pinned at the zone boundary (bulges touching, never overlapping), it + * deswells there on the snap spring, and once it drops under the crossing + * amplitude it steps to the boundary on the pointer's side and grows + * back — a continuous pass-behind, not a die-and-restart. + */ + const coloredZoneFor = (arc: number, slack = 0) => { + for (const port of geometry.ports) { + if (!port.color || port.magnetizable === false) continue + const profile = getPortBulgeProfile(port) + const clearance = profile.plateau / 2 + profile.shoulder + CURSOR_HALF_FOOTPRINT_PX + if (arcDistance(arc, port.s, geometry.length) < clearance + slack) { + return { port, clearance } + } + } + return null + } + const targetZone = coloredZoneFor(cursorS.target) + /* + * Hovering ANY resting knob hands the interaction over to it — the knob + * takes its hover size while the swell deswells away, regardless of + * colour. Same-colour merging still happens in transit (the swell can + * slide across a gray knob gooily), but a direct hover always snaps: + * for knobs narrower than the swell, merging instead lets the swell + * swallow the knob it is supposed to be handing off to. hoveredPortRef + * only ever holds magnetizable ports, so the action menu never triggers + * this. + */ + const snappingToKnob = hoveredPortRef.current !== null + /* + * The crossing latch, not a live threshold test: a threshold alone + * oscillates — the instant amplitude dips below it the target flips back + * to 1 and pumps it up again, and the swell hangs at the boundary + * forever. Latched at the pin, released only by completing the crossing + * or by the swell moving clear of the zone. + */ + if (zoneCrossingRef.current && coloredZoneFor(cursorS.value, 1) === null) { + zoneCrossingRef.current = false + } + const handingOff = Boolean(targetZone) || snappingToKnob || zoneCrossingRef.current + cursorAmplitude.target = cursorHoverAllowedRef.current && !handingOff ? 1 : 0 + if (reducedMotion) { + hoverSpring.value = hoverSpring.target + hoverSpring.velocity = 0 + actionMenuSpring.value = actionMenuSpring.target + actionMenuSpring.velocity = 0 + cursorAmplitude.value = cursorAmplitude.target + cursorAmplitude.velocity = 0 + cursorS.value = cursorS.target + cursorS.velocity = 0 + } else { + springStep(hoverSpring, deltaSeconds, SPRING_STIFFNESS, SPRING_DAMPING, { + min: 0, + max: 1, + }) + springStep( + actionMenuSpring, + deltaSeconds, + ACTION_MENU_SPRING_STIFFNESS, + ACTION_MENU_SPRING_DAMPING, + { min: 0, max: 1 } + ) + /* Hand-offs to a knob deswell on the stiffer snap spring — quick but + still a motion, neither a blink nor a loitering blob. */ + springStep( + cursorAmplitude, + deltaSeconds, + handingOff ? CURSOR_SNAP_SPRING_STIFFNESS : SPRING_STIFFNESS, + handingOff ? CURSOR_SNAP_SPRING_DAMPING : SPRING_DAMPING, + { min: 0, max: 1 } + ) + if (cursorAmplitude.target === 0 && cursorAmplitude.value < 0.005) { + cursorAmplitude.value = 0 + cursorAmplitude.velocity = 0 + } + const delta = shortestArcDelta(cursorS.value, cursorS.target, geometry.length) + const unwrappedTarget = cursorS.value + delta + const originalTarget = cursorS.target + cursorS.target = unwrappedTarget + springStep(cursorS, deltaSeconds) + cursorS.value = modulo(cursorS.value, geometry.length) + cursorS.target = originalTarget + } + + const actionMenuReady = isActionMenuSwellReady( + actionMenuSpring.target, + actionMenuSpring.value + ) + if (actionMenuReady !== actionMenuReadyRef.current) { + actionMenuReadyRef.current = actionMenuReady + onActionMenuReadyChangeRef.current?.(actionMenuReady) + } + + const valueZone = coloredZoneFor(cursorS.value) + if (valueZone) { + if (cursorAmplitude.value <= CURSOR_CROSSING_AMPLITUDE && !targetZone) { + /* Faint enough to cross: step out at the boundary on the pointer's + side, keeping the residual amplitude so growth resumes at once. */ + const toTarget = shortestArcDelta(valueZone.port.s, cursorS.target, geometry.length) + const side = toTarget !== 0 ? Math.sign(toTarget) : -1 + cursorS.value = modulo(valueZone.port.s + side * valueZone.clearance, geometry.length) + cursorS.velocity = 0 + zoneCrossingRef.current = false + } else if (cursorAmplitude.value > 0.005) { + /* Still visible: pinned at the boundary on its current side. */ + const offset = shortestArcDelta(valueZone.port.s, cursorS.value, geometry.length) + const side = offset !== 0 ? Math.sign(offset) : -1 + cursorS.value = modulo(valueZone.port.s + side * valueZone.clearance, geometry.length) + cursorS.velocity = 0 + if (!targetZone) zoneCrossingRef.current = true + } + } + + const activePortId = draggedPortRef.current ?? hoveredPortRef.current + if (cursorAmplitude.value * CURSOR_PEAK_PX >= BULGE_VISIBLE_THRESHOLD_PX && !handingOff) { + const { point } = pointAtArcLength(geometry, cursorS.value) + const edgeSide = + Math.abs(point.nx) > Math.abs(point.ny) + ? point.nx < 0 + ? 'left' + : 'right' + : point.ny < 0 + ? 'top' + : 'bottom' + const side = + edgeSide === 'left' || edgeSide === 'right' + ? edgeSide + : getHorizontalWorkflowHandleSide(point.x, sizeRef.current.width) + onCursorHandleChangeRef.current?.({ + side, + edgeSide, + x: point.x + point.nx * CONNECTION_KNOB_PEAK_PX, + y: point.y + point.ny * CONNECTION_KNOB_PEAK_PX, + }) + } else { + onCursorHandleChangeRef.current?.(null) + } + const maxPortPeak = geometry.ports.reduce( + (current, port) => + Math.max( + current, + getPortPeak(port) * + Math.min( + MAX_PORT_AMPLITUDE, + Math.max( + 0, + Number.isFinite(port.hoverAmplitude) ? (port.hoverAmplitude ?? 1.15) : 1.15 + ) + ) + ), + CURSOR_PEAK_PX + ) + const maxPeak = Math.min( + MAX_SILHOUETTE_OUTSET_PX, + Math.max(maxPortPeak, CURSOR_PEAK_PX * cursorAmplitude.value) + ) + const features: BulgeFeature[] = geometry.ports.map((port) => { + const profile = getPortBulgeProfile(port) + const rest = Math.min( + MAX_PORT_AMPLITUDE, + Math.max(0, Number.isFinite(port.restAmplitude) ? (port.restAmplitude ?? 1) : 1) + ) + const hover = Math.min( + MAX_PORT_AMPLITUDE, + Math.max(0, Number.isFinite(port.hoverAmplitude) ? (port.hoverAmplitude ?? 1.15) : 1.15) + ) + const multiplier = + port.id === 'action-menu' + ? hover * actionMenuSpring.value + : Math.max( + port.revealOnCardHover ? rest + (hover - rest) * hoverSpring.value : rest, + port.id === activePortId ? hover : 0 + ) + return { + center: port.s, + plateau: profile.plateau, + peak: getPortPeak(port) * multiplier, + shoulder: profile.shoulder, + falloff: port.id === 'action-menu' ? 'action-menu' : undefined, + } + }) + if (cursorAmplitude.value >= 0.005) { + features.push({ + center: cursorS.value, + plateau: CURSOR_SWELL_LENGTH_PX * CONNECTION_PLATEAU_RATIO, + peak: CURSOR_PEAK_PX * cursorAmplitude.value, + shoulder: CURSOR_SWELL_LENGTH_PX * CONNECTION_SHOULDER_RATIO, + }) + } + const nextPath = buildPiecewisePath( + { + samples: geometry.samples, + segments: geometry.segments, + length: geometry.length, + }, + features, + maxPeak + ) + const perimeter = { + samples: geometry.samples, + segments: geometry.segments, + length: geometry.length, + } + const actionFeature = features.find((feature) => feature.falloff === 'action-menu') + const actionSwellUp = actionFeature !== undefined && actionFeature.peak > 0.5 + const actionHalf = actionFeature ? actionFeature.plateau / 2 + actionFeature.shoulder : 0 + const nextKnobs = geometry.ports + .map((port, index) => ({ port, feature: features[index] })) + .filter(({ port, feature }) => Boolean(port.color) && feature !== undefined) + /* + * A knob under the open action swell is simply not painted: the swell + * is a lid, and paint that survives beneath it either bleeds through + * mid-animation or reads as the knob changing colour behind the menu. + * The connection anchor (the invisible handle) stays put, so the edge + * still terminates at the card edge — the line just disappears under + * the swell. The knob returns the moment the swell closes. + */ + .filter( + ({ port }) => + !actionSwellUp || + !actionFeature || + arcDistance(port.s, actionFeature.center, geometry.length) > actionHalf + ) + .map(({ port, feature }) => { + const half = visibleBulgeHalf(feature.plateau, feature.shoulder, feature.peak) + /* + * Colour follows only this knob's own bulge. Feeding the full feature + * list (including the action-menu swell) re-displaced the dark stroke + * up onto the ActionBar peak, so the knob looked anchored to the menu + * instead of the card edge and covered the icons. + */ + return { + id: port.id, + color: port.color as string, + d: buildSpanPath(perimeter, [feature], feature.peak, port.s - half, port.s + half), + } + }) + /* + * The action-menu swell must sit OVER any knob or line beneath it: a + * top-centre connection stays anchored to the card edge, and the opening + * swell slides across and hides it. The knobs paint at their resting + * positions above, so the swell's region is repainted after them in the + * silhouette colour — an opaque lid, matching the outline the swell + * already drew. + */ + const nextOverlay = + actionSwellUp && actionFeature + ? buildSpanPath( + perimeter, + features, + maxPeak, + actionFeature.center - actionHalf, + actionFeature.center + actionHalf + ) + : '' + const nextKnobKey = `${nextKnobs.map((knob) => `${knob.id}:${knob.color}:${knob.d}`).join('|')}~${nextOverlay}` + setRenderedPath((current) => + current.d === nextPath.d && current.knobKey === nextKnobKey + ? current + : { ...nextPath, knobs: nextKnobs, knobKey: nextKnobKey, overlay: nextOverlay } + ) + + const moving = + !springAtRest(hoverSpring) || + !springAtRest(actionMenuSpring) || + !springAtRest(cursorAmplitude) || + !springAtRest(cursorS) + if (moving) { + scheduleFrame() + } else { + frameRef.current = null + lastFrameRef.current = 0 + } + } + + const startAnimation = () => { + if (frameRef.current === null) { + lastFrameRef.current = 0 + scheduleFrame() + } + } + startAnimationRef.current = startAnimation + renderNowRef.current = () => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + render(performance.now()) + } + + let trackingPointer = false + let leaveConfirmFrame: number | null = null + const lastPointerRef = { x: 0, y: 0 } + // Include the action-tab / top bridge siblings so opening the action menu + // does not look like a leave and kill the cursor-follow connection swell. + const trackingRoot = (host.closest('.react-flow__node') as HTMLElement | null) ?? host + + const stopPointerTracking = () => { + // Keep the cursor source handle mounted while a connection drag is active; + // clearing it on leave unmounts the Handle and kills the drag. + if (draggedPortRef.current) return + if (leaveConfirmFrame !== null) { + cancelAnimationFrame(leaveConfirmFrame) + leaveConfirmFrame = null + } + hoveredPortRef.current = null + hoverSpringRef.current.target = 0 + cursorHoverAllowedRef.current = false + cursorAmplitudeRef.current.target = 0 + cursorAmplitudeRef.current.velocity = 0 + if (trackingPointer) { + window.removeEventListener('pointermove', onTrackedPointerMove) + trackingPointer = false + } + startAnimation() + } + + const beginPointerTracking = () => { + if (leaveConfirmFrame !== null) { + cancelAnimationFrame(leaveConfirmFrame) + leaveConfirmFrame = null + } + if (trackingPointer) return + trackingPointer = true + window.addEventListener('pointermove', onTrackedPointerMove) + } + + const isWithinTrackingBounds = (clientX: number, clientY: number) => { + const rect = host.getBoundingClientRect() + return ( + clientX >= rect.left - CURSOR_TRACKING_MARGIN_PX && + clientX <= rect.right + CURSOR_TRACKING_MARGIN_PX && + clientY >= rect.top - CURSOR_TRACKING_MARGIN_PX && + clientY <= rect.bottom + CURSOR_TRACKING_MARGIN_PX + ) + } + + const isPointerOverTrackingRoot = (clientX: number, clientY: number) => { + if (isWithinTrackingBounds(clientX, clientY)) return true + const top = document.elementFromPoint(clientX, clientY) + return Boolean(top && trackingRoot.contains(top)) + } + + const updatePointerTarget = (clientX: number, clientY: number) => { + if (draggedPortRef.current) return + if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return + lastPointerRef.x = clientX + lastPointerRef.y = clientY + const { width: layoutWidth, height: layoutHeight } = sizeRef.current + if (layoutWidth <= 0 || layoutHeight <= 0) return + if (!isWithinTrackingBounds(clientX, clientY)) { + // Still over the node chrome (action tab / bridge) — keep listening so + // returning to an edge immediately restores the swell. + if (isPointerOverTrackingRoot(clientX, clientY)) { + cursorHoverAllowedRef.current = false + cursorAmplitudeRef.current.target = 0 + startAnimation() + return + } + stopPointerTracking() + return + } + const rect = host.getBoundingClientRect() + const geometry = geometryRef.current + const scaleX = rect.width / layoutWidth + const scaleY = rect.height / layoutHeight + if (!Number.isFinite(scaleX) || !Number.isFinite(scaleY) || scaleX <= 0 || scaleY <= 0) { + cursorHoverAllowedRef.current = false + cursorAmplitudeRef.current.target = 0 + startAnimation() + return + } + const scale = Math.min(scaleX, scaleY) + const localX = (clientX - rect.left) / scaleX + const localY = (clientY - rect.top) / scaleY + if (!Number.isFinite(localX) || !Number.isFinite(localY) || !Number.isFinite(scale)) { + cursorHoverAllowedRef.current = false + cursorAmplitudeRef.current.target = 0 + startAnimation() + return + } + const nearest = closestSample(geometry.samples, localX, localY) + const nearestPort = geometry.ports + .filter((port) => port.magnetizable !== false) + .map((port) => ({ + port, + distance: arcDistance(nearest.sample.s, port.s, geometry.length), + })) + .sort((a, b) => a.distance - b.distance)[0] + const outwardDistance = + (localX - nearest.sample.x) * nearest.sample.nx + + (localY - nearest.sample.y) * nearest.sample.ny + const edgeDepth = outwardDistance > 0 ? CURSOR_EDGE_OUTSET_PX : CURSOR_EDGE_INSET_PX + const inEdgeZone = nearest.distance * scale <= edgeDepth + /* + * Magnetize toward ANY nearby port, connected knobs included. The cursor + * swell then slides into the existing knob and the two bulges coincide, + * reading as one shape, instead of the swell blinking out as it nears. + */ + const magnetized = + inEdgeZone && + nearestPort !== undefined && + nearestPort.distance * scale <= MAGNETIZE_DISTANCE_PX + const hitElement = document.elementFromPoint(clientX, clientY) + const pointerOverActionControl = + hitElement instanceof Element && + Boolean( + hitElement.closest('[data-workflow-action-bar-swell], [data-workflow-action-bar-bridge]') + ) + /* + * The action bar blocks connection interaction only where its DOM is + * actually hit. Its broad SVG shoulders are visual chrome and must not + * consume the usable top-left/top-right card edge beneath them. + */ + const cursorDisplacementVisible = inEdgeZone && !pointerOverActionControl + cursorSRef.current.target = + magnetized && !nearestPort.port.color ? nearestPort.port.s : nearest.sample.s + /* + * Whether an edge hover is allowed at all. Whether the swell is actually + * drawn is decided per frame in the render loop, against where the swell + * has sprung to rather than where the pointer is — see there for why. + */ + cursorHoverAllowedRef.current = cursorDisplacementVisible + cursorAmplitudeRef.current.target = cursorDisplacementVisible ? 1 : 0 + if (pointerOverActionControl) { + cursorAmplitudeRef.current.value = 0 + cursorAmplitudeRef.current.velocity = 0 + } + hoveredPortRef.current = magnetized ? nearestPort.port.id : null + startAnimation() + } + + const onTrackedPointerMove = (event: PointerEvent) => { + updatePointerTarget(event.clientX, event.clientY) + } + + const onPointerEnter = (event: PointerEvent) => { + hoverSpringRef.current.target = 1 + beginPointerTracking() + updatePointerTarget(event.clientX, event.clientY) + startAnimation() + } + + const onPointerLeave = (event: PointerEvent) => { + const next = event.relatedTarget + if (next instanceof Node && trackingRoot.contains(next)) return + // DOM churn from the action tab / updateNodeInternals often yields a null + // relatedTarget while the pointer is still over the card. Confirm on the + // next frame before tearing the swell down. + if (leaveConfirmFrame !== null) cancelAnimationFrame(leaveConfirmFrame) + leaveConfirmFrame = requestAnimationFrame(() => { + leaveConfirmFrame = null + if (draggedPortRef.current) return + if (isPointerOverTrackingRoot(lastPointerRef.x, lastPointerRef.y)) { + beginPointerTracking() + updatePointerTarget(lastPointerRef.x, lastPointerRef.y) + return + } + stopPointerTracking() + }) + } + + const onPointerDown = (event: PointerEvent) => { + const target = event.target + if (!(target instanceof Element)) return + const handle = target.closest('[data-handleid]') + if (!handle) return + draggedPortRef.current = handle.dataset.handleid ?? null + startAnimation() + } + + const onPointerUp = (event: PointerEvent) => { + draggedPortRef.current = null + if (!isPointerOverTrackingRoot(event.clientX, event.clientY)) { + stopPointerTracking() + return + } + beginPointerTracking() + updatePointerTarget(event.clientX, event.clientY) + } + + trackingRoot.addEventListener('pointerenter', onPointerEnter) + trackingRoot.addEventListener('pointerleave', onPointerLeave) + // Capture before React Flow handle handlers stopPropagation, otherwise + // draggedPortRef stays empty and pointerleave tears down the drag handle. + trackingRoot.addEventListener('pointerdown', onPointerDown, true) + window.addEventListener('pointerup', onPointerUp) + updatePointerTargetRef.current = updatePointerTarget + resetPointerTrackingRef.current = stopPointerTracking + if (trackingRoot.matches(':hover')) { + hoverSpringRef.current.target = 1 + beginPointerTracking() + } + /* + * First paint is synchronous, inside this layout effect, so a card never + * commits a frame without its border. Deferring to the animation frame + * painted the content one frame before the silhouette — every mount + * flashed a bodiless card, and a suspended tab never painted one at all. + */ + render(performance.now()) + + return () => { + startAnimationRef.current = () => {} + renderNowRef.current = () => {} + updatePointerTargetRef.current = () => {} + resetPointerTrackingRef.current = () => {} + onCursorHandleChangeRef.current?.(null) + if (leaveConfirmFrame !== null) cancelAnimationFrame(leaveConfirmFrame) + trackingRoot.removeEventListener('pointerenter', onPointerEnter) + trackingRoot.removeEventListener('pointerleave', onPointerLeave) + trackingRoot.removeEventListener('pointerdown', onPointerDown, true) + window.removeEventListener('pointermove', onTrackedPointerMove) + window.removeEventListener('pointerup', onPointerUp) + if (frameRef.current !== null) cancelAnimationFrame(frameRef.current) + frameRef.current = null + } + }, []) + + useEffect(() => { + /* + * A connection drag captures the pointer on the origin card's handle, so + * this card sees no pointerenter. Window pointermove still fires; feeding + * it into the same tracker gives the dragged line a live edge swell to aim + * at, and magnetization highlights the knob it would land on. + * + * Every card on the canvas listens during a drag, so the cost discipline + * matters: moves are coalesced to one evaluation per frame, that + * evaluation is a single rect test, and a card the pointer is nowhere + * near does nothing at all. Only the transition out of range pays for one + * reset. Without this, a 50-card canvas ran a DOM hit-test and scheduled + * a render on every card for every pointermove of the drag. + */ + let engaged = false + let framePending = false + let foreignFrameId: number | null = null + let pointerX = 0 + let pointerY = 0 + const evaluate = () => { + framePending = false + foreignFrameId = null + const el = svgRef.current?.parentElement + if (!el) return + const rect = el.getBoundingClientRect() + const margin = CURSOR_TRACKING_MARGIN_PX + const inRange = + pointerX >= rect.left - margin && + pointerX <= rect.right + margin && + pointerY >= rect.top - margin && + pointerY <= rect.bottom + margin + if (inRange) { + engaged = true + updatePointerTargetRef.current(pointerX, pointerY) + } else if (engaged) { + engaged = false + resetPointerTrackingRef.current() + } + } + const onForeignMove = (event: PointerEvent) => { + const connectionNodeId = getConnectionNodeId?.() + if (!nodeId || connectionNodeId == null || connectionNodeId === nodeId) return + pointerX = event.clientX + pointerY = event.clientY + if (!framePending) { + framePending = true + foreignFrameId = requestAnimationFrame(evaluate) + } + } + + window.addEventListener('pointermove', onForeignMove) + + return () => { + window.removeEventListener('pointermove', onForeignMove) + if (foreignFrameId !== null) { + cancelAnimationFrame(foreignFrameId) + } + resetPointerTrackingRef.current() + } + }, [getConnectionNodeId, nodeId]) + + const ring = resolveRing(ringStyles) + const { d: path } = renderedPath + const silhouetteColor = hasRing && ring.solid ? ring.color : 'var(--border-1)' + + return ( + + ) +} diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx index 82ce02c9d05..dd1a3cd7093 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-view.tsx @@ -1,38 +1,203 @@ -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 { + type ComponentType, + type CSSProperties, + type ReactNode, + type Ref, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { Badge, ChipTag, cn, handleKeyboardActivation, Switch, Tooltip } from '@sim/emcn' +import { + getPositionedSourceHandleId, + getPositionedTargetHandleId, + POSITIONED_SOURCE_HANDLE_SIDES, + type PositionedSourceHandleSide, +} from '@sim/workflow-types/workflow' +import { + Handle, + internalsSymbol, + Position, + useStoreApi as useReactFlowStoreApi, + useUpdateNodeInternals, +} from 'reactflow' +import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '../dimensions' +import { humanizeBlockName } from '../lib/humanize-block-name' import { OverflowSpan } from '../lib/overflow-span' -import { tileIconColorClass } from '../lib/tile-icon-color' import type { BlockRunStatus } from '../types' +import { + getCursorBranchSourceHandleId, + getCursorSourceHandleId, + getCursorSourceHandlePosition, +} from './source-handle' import { SubBlockRowView } from './sub-block-row-view' +import { + CONNECTION_KNOB_PEAK_PX, + CURSOR_SWELL_LENGTH_PX, + WorkflowBlockBorder, + type WorkflowBorderCursorHandle, + type WorkflowBorderPort, +} from './workflow-block-border' + +const getHandleStyle = (position: 'horizontal' | 'vertical') => { + if (position === 'horizontal') { + return { top: '50%', transform: 'translateY(-50%)' } + } + return { left: '50%', transform: 'translateX(-50%)' } +} + +/** Radial hit depth stays on the painted knob instead of covering card content. */ +const HANDLE_HIT_CROSS_PX = CONNECTION_KNOB_PEAK_PX * 2 +/** Along-edge hit span matches the primary connection knob's painted footprint. */ +const MAIN_HANDLE_HIT_LENGTH_PX = 38 +/** Card bottom → error-row centre: half the content padding + half the row. */ +const TAB_LENGTH_PX = 36 +const TAB_LENGTH_SMALL_PX = 24 +const TAB_LENGTH_MIN_PX = 16 +const TAB_LENGTH_HEADER_ONLY_PX = 10 +/** The error knob is deliberately the shortest of the connection knobs — it is + * a secondary output, and a full-length tab crowds the card's bottom corner. */ +const TAB_HEIGHT_RATIO = 0.5 +const CARD_CORNER_RADIUS_PX = 16 +const CORNER_SLACK_PX = 4 +const ACTION_MENU_FALLBACK_WIDTH_PX = 132 +const ACTION_MENU_RIGHT_INSET_PX = 24 +const ACTION_MENU_MAX_WIDTH_PX = BLOCK_DIMENSIONS.FIXED_WIDTH - ACTION_MENU_RIGHT_INSET_PX * 2 +const ACTION_MENU_AMPLITUDE = 7 +/** Hold the gray swell open briefly on close so icons can fade out first. */ +const ACTION_MENU_CLOSE_RETRACT_DELAY_MS = 40 +/** Ignore brief pointer exits (edges, handles) so hover doesn't flicker closed. */ +const ACTION_MENU_HOVER_LEAVE_DELAY_MS = 100 +/** Extra hit area above the card for the action swell. */ +const ACTION_MENU_HOVER_TOP_PAD_PX = 28 +/** Compile-time pin: the `-7px` handle outsets below must track the knob peak. */ +const HANDLE_OUTSET_PX: typeof CONNECTION_KNOB_PEAK_PX = 7 + +interface BranchCursorRow { + id: string +} + +interface WorkflowCursorSourceHandle extends WorkflowBorderCursorHandle { + handleId: string +} + +/** Resolves a moving branch-card swell to the nearest visible branch row. */ +export function getNearestBranchCursorHandleId( + rows: BranchCursorRow[], + cursorY: number, + firstRowY: number, + handlePrefix: 'condition' | 'router' +): string | null { + if (rows.length === 0) return null + + const nearestIndex = Math.min( + rows.length - 1, + Math.max(0, Math.round((cursorY - firstRowY) / HANDLE_POSITIONS.CONDITION_ROW_HEIGHT)) + ) + return getCursorBranchSourceHandleId(`${handlePrefix}-${rows[nearestIndex].id}`) +} + +const WORKFLOW_TYPE_ACCENTS = { + agent: { variant: 'workflow', tone: 'inverse' }, + api: { variant: 'workflow', tone: 'indigo' }, + condition: { variant: 'workflow', tone: 'violet' }, + credential: { variant: 'workflow', tone: 'amber' }, + file: { variant: 'workflow', tone: 'indigo' }, + file_v5: { variant: 'workflow', tone: 'indigo' }, + function: { variant: 'workflow', tone: 'violet' }, + router_v2: { variant: 'workflow', tone: 'indigoStrong' }, + table: { variant: 'workflow', tone: 'teal' }, +} as const + +const DEFAULT_WORKFLOW_TYPE_ACCENT = { variant: 'workflow', tone: 'neutral' } as const + +export const getWorkflowTypeAccent = (type: string) => + WORKFLOW_TYPE_ACCENTS[type as keyof typeof WORKFLOW_TYPE_ACCENTS] ?? DEFAULT_WORKFLOW_TYPE_ACCENT + +const clampTabLength = (length: number) => + Math.min(TAB_LENGTH_PX, Math.max(TAB_LENGTH_MIN_PX, Math.round(length))) /** - * Reusable styles and positioning for Handle components. + * The outsets below are the anchor every edge terminates at, and they must + * equal the height each knob is painted at — otherwise the line stops short of + * the knob (or overshoots past it). Tailwind only scans literal class strings, + * so the value is spelled out here and pinned to the renderer's constant by the + * assertion above. */ -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', - } +const getInvisibleHandleClasses = (side: 'left' | 'right' | 'top' | 'bottom') => { + const offsetClasses = { + right: '!right-[-7px]', + left: '!left-[-7px]', + top: '!top-[-7px]', + bottom: '!bottom-[-7px]', + } as const + return cn( + '!z-20 !cursor-crosshair !rounded-none !border-none !bg-transparent !opacity-0', + offsetClasses[side] + ) +} - return cn(baseClasses, colorClasses, positionClasses[position]) +/** + * Hit area for a connection port. It follows the painted knob instead of the + * broader edge-hover swell, keeping nearby card content draggable. Main ports + * use the primary knob footprint; branch ports can fill their row lane so + * adjacent targets meet without overlapping while the painted knobs stay + * visually compact. + */ +const invisibleHandleSize = ( + side: 'left' | 'right' | 'top' | 'bottom', + length: number, + minLength = 0 +) => { + const span = Math.max(length, minLength) + return side === 'top' || side === 'bottom' + ? { width: span, height: HANDLE_HIT_CROSS_PX } + : { width: HANDLE_HIT_CROSS_PX, height: span } } -const getHandleStyle = (position: 'horizontal' | 'vertical') => { - if (position === 'horizontal') { - return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' } +const getReactFlowPosition = (side: PositionedSourceHandleSide) => { + return side === 'left' ? Position.Left : Position.Right +} + +const getCenteredSideHandleStyle = (side: PositionedSourceHandleSide): CSSProperties => { + const style: CSSProperties = { + right: 'auto', + bottom: 'auto', + width: 1, + height: 1, } - return { left: '50%', transform: 'translateX(-50%)' } + if (side === 'right') { + return { ...style, top: '50%', left: '100%', transform: 'translate(-50%, -50%)' } + } + return { ...style, top: '50%', left: 0, transform: 'translate(-50%, -50%)' } } +/** Error is the only persisted source that leaves from a vertical card edge. */ +export const ERROR_SOURCE_HANDLE_POSITION = Position.Bottom + +/** Keeps the Error hit target centered on the painted bottom-right knob. */ +export const getErrorSourceHandleStyle = (): CSSProperties => ({ + right: 'auto', + top: 'auto', + bottom: -HANDLE_OUTSET_PX, + left: `calc(100% - ${HANDLE_POSITIONS.ERROR_RIGHT_OFFSET}px)`, + width: TAB_LENGTH_SMALL_PX, + height: HANDLE_HIT_CROSS_PX, + transform: 'translateX(-50%)', +}) + +/** Builds the fixed Error knob painted into the card's bottom edge. */ +export const getErrorBorderPort = (color?: string): WorkflowBorderPort => ({ + id: 'error', + side: 'bottom', + position: { fromEnd: HANDLE_POSITIONS.ERROR_RIGHT_OFFSET }, + plateau: TAB_LENGTH_SMALL_PX, + color, +}) + /** * Props for the pure workflow-block renderer. * @@ -62,6 +227,12 @@ export interface WorkflowBlockViewProps { /** Handle orientation and topology, resolved by the container. */ horizontalHandles: boolean shouldShowDefaultHandles: boolean + /** + * Deterministic card height in px, used to scale the side connection tabs + * so a short card never wears a tab nearly as tall as itself. Omit to keep + * the full-size tabs (read-only contexts without computed dimensions). + */ + blockHeight?: number hasContentBelowHeader: boolean conditionRows: { id: string; title: string; value: string }[] routerRows: { id: string; value: string }[] @@ -113,6 +284,42 @@ export interface WorkflowBlockViewProps { * conditionRows/routerRows. */ rows: ReactNode + /** + * Inline statement fragments rendered as one line above the rows (operation + * · target). Built by the container; standard blocks only — condition/router + * blocks render their branch rows instead. + */ + chips?: ReactNode + /** + * Block-kind label (e.g. "Table", "Agent") shown as a ChipTag on the header + * right. Hidden when it matches the block name to avoid duplication. + */ + typeLabel?: string + /** + * Natural-language summary of what the block does, with inline value + * chips. When present it replaces the statement line and field rows; + * the error footer stays. + */ + sentence?: ReactNode + /** + * Whether a persisted legacy error route is wired from this block. Used + * only to retain a non-interactive edge anchor for existing workflows. + */ + hasErrorConnection?: boolean + /** Whether this block's error output is switched on. */ + errorOutputEnabled?: boolean + /** Toggles the error output from the card's error row. */ + onToggleErrorOutput?: (enabled: boolean) => void + /** + * Handle ids whose connected edge is currently highlighted (an endpoint + * block is selected) — their tabs darken to match the edge color so the + * line and its port read as one piece. + */ + highlightedHandles?: ReadonlySet + /** Side-specific source handles currently used by persisted edges. */ + connectedSourceHandles?: ReadonlySet + /** Side-specific target handles currently used by persisted edges. */ + connectedTargetHandles?: ReadonlySet } /** @@ -132,8 +339,8 @@ export function WorkflowBlockView({ runPathStatus, Icon, iconBgColor, - horizontalHandles, shouldShowDefaultHandles, + blockHeight, hasContentBelowHeader, conditionRows, routerRows, @@ -165,9 +372,421 @@ export function WorkflowBlockView({ contentRef, actionBar, rows, + chips, + typeLabel, + sentence, + hasErrorConnection = false, + errorOutputEnabled = false, + onToggleErrorOutput, + highlightedHandles, + connectedSourceHandles, + connectedTargetHandles, }: WorkflowBlockViewProps) { + const updateNodeInternals = useUpdateNodeInternals() + const reactFlowStore = useReactFlowStoreApi() + const getConnectionNodeId = useCallback( + () => reactFlowStore.getState().connectionNodeId, + [reactFlowStore] + ) + const supportsPositionedHandles = + type !== 'condition' && type !== 'router_v2' && type !== 'response' + const supportsCursorHandle = type !== 'response' + const cursorSourceHandleRef = useRef(null) + const cursorSourceHandleKeyRef = useRef(null) + const [cursorSourceHandle, setCursorSourceHandle] = useState( + null + ) + const onCursorHandleChange = useCallback( + (nextHandle: WorkflowBorderCursorHandle | null) => { + if (!supportsCursorHandle) return + if (!nextHandle) { + if (cursorSourceHandleKeyRef.current === null) return + cursorSourceHandleKeyRef.current = null + setCursorSourceHandle(null) + return + } + + const handleId = + type === 'condition' + ? getNearestBranchCursorHandleId( + conditionRows, + nextHandle.y, + HANDLE_POSITIONS.CONDITION_START_Y, + 'condition' + ) + : type === 'router_v2' + ? getNearestBranchCursorHandleId( + routerRows, + nextHandle.y, + HANDLE_POSITIONS.CONDITION_START_Y + HANDLE_POSITIONS.CONDITION_ROW_HEIGHT, + 'router' + ) + : getCursorSourceHandleId(nextHandle.side) + if (!handleId) return + + const handleElement = cursorSourceHandleRef.current + if (handleElement) { + handleElement.style.left = `${nextHandle.x}px` + handleElement.style.top = `${nextHandle.y}px` + } + const nextKey = `${handleId}:${nextHandle.edgeSide}` + if (cursorSourceHandleKeyRef.current !== nextKey) { + cursorSourceHandleKeyRef.current = nextKey + setCursorSourceHandle({ ...nextHandle, handleId }) + } + }, + [conditionRows, routerRows, supportsCursorHandle, type] + ) + /** + * Keeps React Flow's cached origin aligned with the transient DOM handle + * without remeasuring the node or publishing a canvas-wide store update. + */ + const syncCursorSourceHandleBounds = useCallback(() => { + const handleElement = cursorSourceHandleRef.current + const nodeElement = handleElement?.closest('.react-flow__node') ?? null + if (!handleElement || !nodeElement) return + + const state = reactFlowStore.getState() + const sourceBounds = state.nodeInternals.get(id)?.[internalsSymbol]?.handleBounds?.source + const handleId = handleElement.dataset.handleid + const handlePosition = handleElement.dataset.handlepos as Position | undefined + const zoom = state.transform[2] + if (!sourceBounds || !handleId || !handlePosition || zoom <= 0) return + + const nodeBounds = nodeElement.getBoundingClientRect() + const handleBounds = handleElement.getBoundingClientRect() + const [originX, originY] = state.nodeOrigin + const nextBounds = { + id: handleId, + position: handlePosition, + x: (handleBounds.left - nodeBounds.left - nodeBounds.width * originX) / zoom, + y: (handleBounds.top - nodeBounds.top - nodeBounds.height * originY) / zoom, + width: handleElement.offsetWidth, + height: handleElement.offsetHeight, + } + const currentBounds = sourceBounds.find((bounds) => bounds.id === handleId) + if (currentBounds) { + Object.assign(currentBounds, nextBounds) + return + } + sourceBounds.push(nextBounds) + }, [id, reactFlowStore]) + const [actionMenuFocused, setActionMenuFocused] = useState(false) + const [actionMenuHovered, setActionMenuHovered] = useState(false) + const actionMenuRootRef = useRef(null) + const actionMenuHostRef = useRef(null) + const actionMenuHoverLeaveTimeoutRef = useRef(null) + const actionMenuHoverMoveRef = useRef<((event: PointerEvent) => void) | null>(null) + const [actionMenuWidth, setActionMenuWidth] = useState(ACTION_MENU_FALLBACK_WIDTH_PX) + const [actionMenuSwellReady, setActionMenuSwellReady] = useState(false) + const isNodeSelected = hasRing && ringStyles.includes('--text-secondary') + const keepActionMenuOpen = actionMenuFocused || actionMenuHovered || isNodeSelected + const actionMenuContentVisible = keepActionMenuOpen && actionMenuSwellReady + const [actionMenuSwellOpen, setActionMenuSwellOpen] = useState(keepActionMenuOpen) + const showActionMenu = Boolean(actionBar) + const typeAccent = getWorkflowTypeAccent(type) + + useEffect(() => { + if (keepActionMenuOpen) { + setActionMenuSwellOpen(true) + return + } + const timeoutId = window.setTimeout(() => { + setActionMenuSwellOpen(false) + }, ACTION_MENU_CLOSE_RETRACT_DELAY_MS) + return () => window.clearTimeout(timeoutId) + }, [keepActionMenuOpen]) + + useEffect(() => { + if (!showActionMenu) return + + const clearHoverLeave = () => { + if (actionMenuHoverLeaveTimeoutRef.current !== null) { + window.clearTimeout(actionMenuHoverLeaveTimeoutRef.current) + actionMenuHoverLeaveTimeoutRef.current = null + } + if (actionMenuHoverMoveRef.current) { + window.removeEventListener('pointermove', actionMenuHoverMoveRef.current) + actionMenuHoverMoveRef.current = null + } + } + + const isPointerOverActionMenu = (event: PointerEvent) => { + const root = actionMenuRootRef.current + if (!root) return false + const rect = root.getBoundingClientRect() + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top - ACTION_MENU_HOVER_TOP_PAD_PX && + event.clientY <= rect.bottom + ) + } + + const openActionMenuHover = () => { + clearHoverLeave() + setActionMenuHovered(true) + setActionMenuSwellOpen(true) + } + + const scheduleActionMenuHoverLeave = () => { + if (actionMenuHoverLeaveTimeoutRef.current !== null) { + window.clearTimeout(actionMenuHoverLeaveTimeoutRef.current) + } + if (!actionMenuHoverMoveRef.current) { + const onMove = (event: PointerEvent) => { + if (isPointerOverActionMenu(event)) { + openActionMenuHover() + } + } + actionMenuHoverMoveRef.current = onMove + window.addEventListener('pointermove', onMove, { passive: true }) + } + actionMenuHoverLeaveTimeoutRef.current = window.setTimeout(() => { + actionMenuHoverLeaveTimeoutRef.current = null + if (actionMenuHoverMoveRef.current) { + window.removeEventListener('pointermove', actionMenuHoverMoveRef.current) + actionMenuHoverMoveRef.current = null + } + setActionMenuHovered(false) + }, ACTION_MENU_HOVER_LEAVE_DELAY_MS) + } + + // Bind to the React Flow node wrapper so edges/handles that sit above the + // card don't permanently steal hover from the swell. + const root = actionMenuRootRef.current + const nodeEl = root?.closest('.react-flow__node') ?? root + if (!nodeEl) return + + const onEnter = () => openActionMenuHover() + const onLeave = () => scheduleActionMenuHoverLeave() + nodeEl.addEventListener('pointerenter', onEnter) + nodeEl.addEventListener('pointerleave', onLeave) + + return () => { + clearHoverLeave() + nodeEl.removeEventListener('pointerenter', onEnter) + nodeEl.removeEventListener('pointerleave', onLeave) + } + }, [showActionMenu]) + /* Blocks that can emit an error always carry the row; `response` terminates + the flow and has no error branch. */ + const showErrorRow = shouldShowDefaultHandles && type !== 'response' + /* + * The error output is a real, draggable source whenever the toggle is on (a + * connection forces the toggle on, so connected cards always have it). It + * doubles as the edge anchor for existing error connections. + */ + const rendersErrorHandle = showErrorRow && (errorOutputEnabled || hasErrorConnection) + useEffect(() => { + if (supportsCursorHandle) return + cursorSourceHandleKeyRef.current = null + setCursorSourceHandle(null) + }, [supportsCursorHandle]) + useLayoutEffect(() => { + updateNodeInternals(id) + }, [ + cursorSourceHandle?.handleId, + cursorSourceHandle?.side, + id, + /* The error handle mounts with the toggle; without a refresh React Flow + keeps stale handleBounds and drops the edge (error 008) until something + else re-measures the node. */ + rendersErrorHandle, + updateNodeInternals, + ]) + useLayoutEffect(() => { + const actionMenu = actionMenuHostRef.current?.querySelector( + '[data-workflow-action-bar-swell]' + ) + if (!actionMenu) return + const updateWidth = () => { + const nextWidth = Math.min(ACTION_MENU_MAX_WIDTH_PX, actionMenu.offsetWidth) + if (nextWidth <= 0) return + setActionMenuWidth((current) => (current === nextWidth ? current : nextWidth)) + } + updateWidth() + const observer = new ResizeObserver(updateWidth) + observer.observe(actionMenu) + return () => observer.disconnect() + }, [showActionMenu]) + const tabFill = (handleId: string) => + highlightedHandles?.has(handleId) ? 'var(--text-secondary)' : undefined + + /* Side tabs scale with the card: half the card height, clamped to 16-36px, + so both default ports on a two-port card stay identical while a + header-only card gets a proportionally short tab. A second cap keeps the + bulge junctions on the straight border segment, clear of the rounded + corners where the outline curves away from the tab's wall. Header-only + trigger cards do not always expose a computed height, so their fallback + stays at the minimum. */ + const sideTabLength = !hasContentBelowHeader + ? TAB_LENGTH_HEADER_ONLY_PX + : blockHeight && blockHeight > 0 + ? clampTabLength( + Math.min( + blockHeight * TAB_HEIGHT_RATIO, + blockHeight - 2 * (CARD_CORNER_RADIUS_PX - CORNER_SLACK_PX) + ) + ) + : TAB_LENGTH_MIN_PX + const mainTabLength = (side: 'left' | 'right' | 'top' | 'bottom') => + side === 'top' || side === 'bottom' ? TAB_LENGTH_PX : sideTabLength + + /* Per-row branch ports shrink as rows multiply (24px for two rows, -2px per + extra row, floored at 16px) so a long stack keeps air between the bumps + within the fixed 29px row pitch. */ + const branchRowCount = type === 'condition' ? conditionRows.length : routerRows.length + const rowTabLength = clampTabLength( + branchRowCount <= 2 ? TAB_LENGTH_SMALL_PX : TAB_LENGTH_SMALL_PX - (branchRowCount - 2) * 2 + ) + const defaultTargetSide: PositionedSourceHandleSide = 'left' + const defaultSourceSide: PositionedSourceHandleSide = 'right' + const borderPorts = useMemo(() => { + const ports: WorkflowBorderPort[] = [] + if (shouldShowDefaultHandles) { + ports.push({ + id: 'target', + side: defaultTargetSide, + position: 'center', + plateau: mainTabLength(defaultTargetSide), + color: + tabFill('target') ?? + tabFill(getPositionedSourceHandleId(defaultTargetSide)) ?? + tabFill(getPositionedTargetHandleId(defaultTargetSide)), + }) + } + if (type === 'condition') { + conditionRows.forEach((condition, index) => { + ports.push({ + id: `condition-${condition.id}`, + side: 'right', + position: + HANDLE_POSITIONS.CONDITION_START_Y + index * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT, + plateau: rowTabLength, + color: tabFill(`condition-${condition.id}`), + }) + }) + } else if (type === 'router_v2') { + routerRows.forEach((route, index) => { + ports.push({ + id: `router-${route.id}`, + side: 'right', + position: + HANDLE_POSITIONS.CONDITION_START_Y + + (index + 1) * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT, + plateau: rowTabLength, + color: tabFill(`router-${route.id}`), + }) + }) + } else if (type !== 'response') { + ports.push({ + id: 'source', + side: defaultSourceSide, + position: 'center', + plateau: mainTabLength(defaultSourceSide), + color: + tabFill('source') ?? + tabFill(getPositionedSourceHandleId(defaultSourceSide)) ?? + tabFill(getPositionedTargetHandleId(defaultSourceSide)), + }) + } + if (supportsPositionedHandles) { + for (const side of POSITIONED_SOURCE_HANDLE_SIDES) { + const handleId = getPositionedSourceHandleId(side) + const sharesDefaultPort = + side === defaultSourceSide || (shouldShowDefaultHandles && side === defaultTargetSide) + if (!connectedSourceHandles?.has(handleId) || sharesDefaultPort) continue + ports.push({ + id: handleId, + side, + position: 'center', + plateau: mainTabLength(side), + color: tabFill(handleId) ?? tabFill(getPositionedTargetHandleId(side)), + }) + } + } + for (const side of POSITIONED_SOURCE_HANDLE_SIDES) { + const handleId = getPositionedTargetHandleId(side) + const sharesDefaultPort = + (shouldShowDefaultHandles && side === defaultTargetSide) || + side === defaultSourceSide || + connectedSourceHandles?.has(getPositionedSourceHandleId(side)) + if (!connectedTargetHandles?.has(handleId) || sharesDefaultPort) continue + ports.push({ + id: handleId, + side, + position: 'center', + plateau: mainTabLength(side), + color: tabFill(handleId), + }) + } + if (showErrorRow && errorOutputEnabled) { + ports.push(getErrorBorderPort(tabFill('error'))) + } + if (showActionMenu) { + ports.push({ + id: 'action-menu', + side: 'top', + position: { fromEnd: ACTION_MENU_RIGHT_INSET_PX + actionMenuWidth / 2 }, + plateau: actionMenuWidth, + restAmplitude: actionMenuSwellOpen ? ACTION_MENU_AMPLITUDE : 0, + hoverAmplitude: ACTION_MENU_AMPLITUDE, + magnetizable: false, + }) + } + return ports + }, [ + conditionRows, + connectedSourceHandles, + connectedTargetHandles, + actionMenuSwellOpen, + actionMenuWidth, + defaultSourceSide, + defaultTargetSide, + highlightedHandles, + routerRows, + rowTabLength, + shouldShowDefaultHandles, + showActionMenu, + showErrorRow, + errorOutputEnabled, + sideTabLength, + supportsPositionedHandles, + type, + ]) + return ( -
+
+ {showActionMenu && ( + <> +