diff --git a/apps/realtime/src/database/operations.ts b/apps/realtime/src/database/operations.ts index cec1c8c0ba4..a0b548b4efa 100644 --- a/apps/realtime/src/database/operations.ts +++ b/apps/realtime/src/database/operations.ts @@ -32,6 +32,8 @@ import { isKnownWorkflowTriggerBlock, isWorkflowAnnotationOnlyBlockType, isWorkflowBlockProtected, + normalizeWorkflowEdgeSourceHandle, + normalizeWorkflowEdgeTargetHandle, } from '@sim/workflow-types/workflow' import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' @@ -57,13 +59,21 @@ function toEdgeHandles(edge: PersistedEdgeRecord) { } interface EdgeAddCandidate { - id?: string + id: string source: string target: string sourceHandle?: string | null targetHandle?: string | null } +function canonicalizeEdgeAddCandidate(edge: EdgeAddCandidate): EdgeAddCandidate { + return { + ...edge, + sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle), + } +} + interface FilterEdgesForPersistResult { safeEdges: T[] droppedCounts: Record @@ -283,8 +293,8 @@ async function insertAutoConnectEdge( workflowId, sourceBlockId: autoConnectEdge.source, targetBlockId: autoConnectEdge.target, - sourceHandle: autoConnectEdge.sourceHandle || null, - targetHandle: autoConnectEdge.targetHandle || null, + sourceHandle: normalizeWorkflowEdgeSourceHandle(autoConnectEdge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(autoConnectEdge.targetHandle), }) logger.debug( `Added auto-connect edge ${autoConnectEdge.id}: ${autoConnectEdge.source} -> ${autoConnectEdge.target}` @@ -736,6 +746,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') @@ -1024,8 +1061,8 @@ async function handleBlocksOperationTx( // blocksById lookup (a plain `tx.select` from `workflowBlocks`) also // sees the blocks this same batch just inserted — reads observe a // transaction's own prior writes. - const candidates: EdgeAddCandidate[] = (edges as Array>).map( - (e) => ({ + const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => + canonicalizeEdgeAddCandidate({ id: e.id as string, source: e.source as string, target: e.target as string, @@ -1051,8 +1088,8 @@ async function handleBlocksOperationTx( workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle), })) await tx @@ -1509,18 +1546,17 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str throw new Error('Missing required fields for add edge operation') } + const candidate = canonicalizeEdgeAddCandidate({ + id: payload.id, + source: payload.source, + target: payload.target, + sourceHandle: payload.sourceHandle ?? null, + targetHandle: payload.targetHandle ?? null, + }) const { safeEdges, droppedCounts, droppedDuplicates } = await filterEdgesForPersist( tx, workflowId, - [ - { - id: payload.id, - source: payload.source, - target: payload.target, - sourceHandle: payload.sourceHandle ?? null, - targetHandle: payload.targetHandle ?? null, - }, - ] + [candidate] ) if (safeEdges.length === 0) { @@ -1534,13 +1570,14 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str break } + const [safeEdge] = safeEdges await tx.insert(workflowEdges).values({ - id: payload.id, + id: safeEdge.id, workflowId, - sourceBlockId: payload.source, - targetBlockId: payload.target, - sourceHandle: payload.sourceHandle || null, - targetHandle: payload.targetHandle || null, + sourceBlockId: safeEdge.source, + targetBlockId: safeEdge.target, + sourceHandle: normalizeWorkflowEdgeSourceHandle(safeEdge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(safeEdge.targetHandle), }) logger.debug(`Added edge ${payload.id}: ${payload.source} -> ${payload.target}`) @@ -1755,13 +1792,15 @@ async function handleEdgesOperationTx( logger.info(`Batch adding ${edges.length} edges to workflow ${workflowId}`) - const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => ({ - id: e.id as string, - source: e.source as string, - target: e.target as string, - sourceHandle: (e.sourceHandle as string | null) ?? null, - targetHandle: (e.targetHandle as string | null) ?? null, - })) + const candidates: EdgeAddCandidate[] = (edges as Array>).map((e) => + canonicalizeEdgeAddCandidate({ + id: e.id as string, + source: e.source as string, + target: e.target as string, + sourceHandle: (e.sourceHandle as string | null) ?? null, + targetHandle: (e.targetHandle as string | null) ?? null, + }) + ) const { safeEdges, droppedCounts, droppedDuplicates, droppedCyclic } = await filterEdgesForPersist(tx, workflowId, candidates) @@ -1784,8 +1823,8 @@ async function handleEdgesOperationTx( workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: normalizeWorkflowEdgeSourceHandle(edge.sourceHandle), + targetHandle: normalizeWorkflowEdgeTargetHandle(edge.targetHandle), })) await tx @@ -2152,16 +2191,34 @@ async function handleWorkflowOperationTx( // Insert all edges from the new state if (edges && edges.length > 0) { - const edgeValues = edges.map((edge: any) => ({ + const canonicalEdges = (edges as Array>).map((edge) => + canonicalizeEdgeAddCandidate({ + id: edge.id as string, + source: edge.source as string, + target: edge.target as string, + sourceHandle: (edge.sourceHandle as string | null) ?? null, + targetHandle: (edge.targetHandle as string | null) ?? null, + }) + ) + const uniqueEdges = filterUniqueWorkflowEdges(canonicalEdges, []) + const edgeValues = uniqueEdges.map((edge) => ({ id: edge.id, workflowId, sourceBlockId: edge.source, targetBlockId: edge.target, - sourceHandle: edge.sourceHandle || null, - targetHandle: edge.targetHandle || null, + sourceHandle: edge.sourceHandle ?? null, + targetHandle: edge.targetHandle ?? null, })) - await tx.insert(workflowEdges).values(edgeValues) + if (uniqueEdges.length < edges.length) { + logger.info(`Dropped ${edges.length - uniqueEdges.length} duplicate edge(s)`, { + operation: WORKFLOW_OPERATIONS.REPLACE_STATE, + }) + } + + if (edgeValues.length > 0) { + await tx.insert(workflowEdges).values(edgeValues) + } } // Insert all loops from the new state diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index 779814257bf..93ab86ab804 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/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx index 27ace3935de..e574b958e7d 100644 --- a/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/navbar-shell/navbar-shell.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { createContext, use, useEffect, useMemo, useRef, useState } from 'react' import { cn } from '@sim/emcn' +import { FROSTED_GLASS_SURFACE } from '@/lib/ui/glass-surface' /** * Frosted near-white surface for the scrolled bar - `--bg` at 92% + a strong 40px @@ -10,8 +11,7 @@ import { cn } from '@sim/emcn' * dropdown sheet ({@link MobileNav}) wears the exact same glass as the bar and the * two can never drift. */ -export const NAVBAR_GLASS_SURFACE = - 'bg-[color-mix(in_srgb,var(--bg)_92%,transparent)] backdrop-blur-2xl' +export const NAVBAR_GLASS_SURFACE = FROSTED_GLASS_SURFACE interface NavbarFrostContextValue { /** 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..40cd79459a8 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,7 +1,8 @@ -import { memo, useCallback } from 'react' +import { memo, useCallback, useEffect, useState } 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 { ThinkingLoader } from '@/components/ui' import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' @@ -12,16 +13,67 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 } +const PROGRESS_ACTIONS_REVEAL_DELAY_MS = 120 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]' +const PROGRESS_LEFT_CAP_PATH = + 'M23.75 0A8 8 0 0 0 17.6 2.88L3.41 19.9A2.5 2.5 0 0 0 5.34 24L40 24L40 0Z' +const PROGRESS_RIGHT_CAP_PATH = + 'M16.25 0A8 8 0 0 1 22.4 2.88L36.59 19.9A2.5 2.5 0 0 1 34.66 24L0 24L0 0Z' + +type ActionId = 'run' | 'enabled' | 'lock' | 'duplicate' | 'remove' | 'delete' + +function IndeterminateBlockProgress() { + return ( +
+ + ) +} /** * Props for the ActionBar component @@ -33,6 +85,10 @@ 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' + /** Whether the current workflow is executing. */ + isRunning?: boolean } /** @@ -42,17 +98,21 @@ interface ActionBarProps { * @component */ export const ActionBar = memo( - function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) { + function ActionBar({ + blockId, + blockType, + disabled = false, + variant = 'floating', + isRunning = false, + }: ActionBarProps) { const { collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, collaborativeBatchToggleBlockEnabled, - collaborativeBatchToggleBlockHandles, collaborativeBatchToggleLocked, } = useCollaborativeWorkflow() const { setPendingSelection } = useWorkflowRegistry() const { handleRunFromBlock } = useWorkflowExecution() - const handleDuplicateBlock = useCallback(() => { const { copyBlocks, preparePasteData } = useWorkflowRegistry.getState() const existingBlocks = useWorkflowStore.getState().blocks @@ -78,32 +138,40 @@ 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 [actionsSuppressed, setActionsSuppressed] = useState(isRunning) + const shouldSuppressActions = isRunning || actionsSuppressed + + useEffect(() => { + if (isRunning) { + setActionsSuppressed(true) + return + } + + const timer = window.setTimeout( + () => setActionsSuppressed(false), + PROGRESS_ACTIONS_REVEAL_DELAY_MS + ) + return () => window.clearTimeout(timer) + }, [isRunning]) + const isExecuting = useIsCurrentWorkflowExecuting() const getLastExecutionSnapshot = useExecutionStore((s) => s.getLastExecutionSnapshot) const userPermissions = useUserPermissionsContext() @@ -112,7 +180,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 +199,64 @@ 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, + ((actionId === 'enabled' && !isEnabled) || (actionId === 'lock' && isLocked)) && [ + 'bg-[var(--text-secondary)] text-[var(--text-inverse)]', + ], + 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 +278,235 @@ 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' - })()} - - - )} +
+ {isSwell && isRunning && ( +
+ +
+ )} +
+
+ {!isNoteBlock && !isInsideSubflow && ( + + + + + + + + {(() => { + if (isLocked || isParentLocked) return 'Block is locked' + if (disabled) return getTooltipMessage('Run') + if (isExecuting) return 'Running...' + if (!dependenciesSatisfied) return 'Run previous blocks first' + return 'Run' + })()} + + + )} - {!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' : 'Enable')} + + + )} - {userPermissions.canAdmin && ( - - - - - - {isLocked && isParentLocked - ? 'Parent container is locked' - : isLocked - ? 'Unlock Block' - : 'Lock Block'} - - - )} + {userPermissions.canAdmin && ( + + + + + + + + {isLocked && isParentLocked + ? 'Parent container is locked' + : isLocked + ? 'Unlock' + : 'Lock'} + + + )} - {!isStartBlock && !isResponseBlock && ( - - - - - - {isLocked || isParentLocked - ? 'Block is locked' - : getTooltipMessage('Duplicate Block')} - - - )} + {!isStartBlock && !isResponseBlock && ( + + + + + + + + {isLocked || isParentLocked + ? 'Block is locked' + : getTooltipMessage('Duplicate')} + + + )} - {!isNoteBlock && !isSubflowBlock && ( - - - + + + + {isLocked || isParentLocked + ? 'Block is locked' + : getTooltipMessage('Remove from Subflow')} + + )} - - - - {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')} - - - )} - - - - - - {isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')} - - + + + + + + + + {isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete')} + + +
+
+
) }, @@ -367,7 +522,9 @@ 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 && + prevProps.isRunning === nextProps.isRunning ) } ) 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 && ( void +} + +type RecentSelection = + | { kind: 'block'; id: string } + | { kind: 'tool'; id: string } + | { kind: 'tool_operation'; id: string } + +type RecentPickerResult = + | { kind: 'block'; item: SearchBlockItem } + | { kind: 'tool'; item: SearchBlockItem } + | { kind: 'tool_operation'; item: SearchToolOperationItem } + +function getBlockCommandValue(item: SearchBlockItem, kind: 'block' | 'tool'): string { + return `${item.name} ${kind}-${item.id}` +} + +function getToolOperationCommandValue(item: SearchToolOperationItem): string { + return `${item.searchValue} operation-${item.id}` +} + +function getPickerResultCommandValue(result: RecentPickerResult): string { + return result.kind === 'tool_operation' + ? getToolOperationCommandValue(result.item) + : getBlockCommandValue(result.item, result.kind) +} + +function getRecentCommandValue(result: RecentPickerResult): string { + return `recent ${getPickerResultCommandValue(result)}` +} + +function isRecentSelection(value: unknown): value is RecentSelection { + if ( + !value || + typeof value !== 'object' || + !('kind' in value) || + !('id' in value) || + typeof value.id !== 'string' + ) { + return false + } + return value.kind === 'block' || value.kind === 'tool' || value.kind === 'tool_operation' +} + +/** + * Transient on-canvas block picker shown after a connection is released on the + * pane. Its outer silhouette is the canonical workflow-card border, so the + * temporary edge reads as connected to the block that will replace it. + */ +export function ConnectionBlockSelector({ id, data }: NodeProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + const currentWorkflowId = params.workflowId as string | undefined + const posthog = usePostHog() + const inputRef = useRef(null) + const listRef = useRef(null) + const [search, setSearch] = useState('') + const [selectedValue, setSelectedValue] = useState('') + const [recentSelections, setRecentSelections] = useState([]) + const deferredSearch = useDeferredValue(search) + const isSearching = deferredSearch.trim().length > 0 + const recentStorageKey = `${RECENT_SELECTION_STORAGE_PREFIX}:${workspaceId}` + const { blocks, tools, toolOperations } = useSearchModalStore((state) => state.data) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') data.onClose() + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [data]) + + useEffect(() => { + try { + const storedSelections = window.localStorage.getItem(recentStorageKey) + if (!storedSelections) { + setRecentSelections([]) + return + } + const parsedSelections: unknown = JSON.parse(storedSelections) + setRecentSelections( + Array.isArray(parsedSelections) + ? parsedSelections.filter(isRecentSelection).slice(0, RECENT_SELECTION_LIMIT) + : [] + ) + } catch { + setRecentSelections([]) + } + }, [recentStorageKey]) + + const availableBlocks = useMemo( + () => + blocks.filter( + (block) => !block.sourceWorkflowId || block.sourceWorkflowId !== currentWorkflowId + ), + [blocks, currentWorkflowId] + ) + + const availableTools = useMemo( + () => + tools.filter((tool) => !tool.sourceWorkflowId || tool.sourceWorkflowId !== currentWorkflowId), + [currentWorkflowId, tools] + ) + + const searchCandidates = useMemo( + () => [ + ...availableBlocks.map((item): RecentPickerResult => ({ kind: 'block', item })), + ...availableTools.map((item): RecentPickerResult => ({ kind: 'tool', item })), + ...toolOperations.map((item): RecentPickerResult => ({ kind: 'tool_operation', item })), + ], + [availableBlocks, availableTools, toolOperations] + ) + + const searchResults = useMemo( + () => + deferredSearch.trim() + ? filterAndCap( + searchCandidates, + (result) => result.item.name, + deferredSearch, + (result) => result.item.searchValue + ) + : [], + [deferredSearch, searchCandidates] + ) + + const recentSelectionKeys = useMemo( + () => new Set(recentSelections.map((selection) => `${selection.kind}:${selection.id}`)), + [recentSelections] + ) + + const recentResults = useMemo(() => { + const blocksById = new Map(availableBlocks.map((block) => [block.id, block])) + const toolsById = new Map(availableTools.map((tool) => [tool.id, tool])) + const operationsById = new Map(toolOperations.map((operation) => [operation.id, operation])) + const results: RecentPickerResult[] = [] + + for (const selection of recentSelections) { + if (selection.kind === 'block') { + const item = blocksById.get(selection.id) + if (item) results.push({ kind: 'block', item }) + } else if (selection.kind === 'tool') { + const item = toolsById.get(selection.id) + if (item) results.push({ kind: 'tool', item }) + } else { + const item = operationsById.get(selection.id) + if (item) results.push({ kind: 'tool_operation', item }) + } + } + + return results.slice(0, RECENT_SELECTION_LIMIT) + }, [availableBlocks, availableTools, recentSelections, toolOperations]) + + const popularBlocks = useMemo( + () => + POPULAR_BLOCK_TYPES.map((type) => availableBlocks.find((block) => block.type === type)) + .filter((block): block is SearchBlockItem => Boolean(block)) + .filter((block) => !recentSelectionKeys.has(`block:${block.id}`)) + .slice(0, RECENT_SELECTION_LIMIT), + [availableBlocks, recentSelectionKeys] + ) + + const popularBlockIds = useMemo( + () => new Set(popularBlocks.map((block) => block.id)), + [popularBlocks] + ) + + const browseBlocks = useMemo( + () => + availableBlocks.filter( + (block) => !recentSelectionKeys.has(`block:${block.id}`) && !popularBlockIds.has(block.id) + ), + [availableBlocks, popularBlockIds, recentSelectionKeys] + ) + + const browseTools = useMemo( + () => availableTools.filter((tool) => !recentSelectionKeys.has(`tool:${tool.id}`)), + [availableTools, recentSelectionKeys] + ) + + const dispatchSelection = useCallback( + (type: string, resultType: 'block' | 'tool' | 'tool_operation', presetOperation?: string) => { + window.dispatchEvent( + new CustomEvent('add-block-from-toolbar', { + detail: { + type, + presetOperation, + pendingConnect: data.pendingConnect, + }, + }) + ) + captureEvent(posthog, 'search_result_selected', { + result_type: resultType, + query_length: deferredSearch.length, + workspace_id: workspaceId, + }) + data.onClose() + }, + [data, deferredSearch.length, posthog, workspaceId] + ) + + const rememberSelection = useCallback( + (selection: RecentSelection) => { + const nextSelections = [ + selection, + ...recentSelections.filter( + (recent) => recent.kind !== selection.kind || recent.id !== selection.id + ), + ].slice(0, RECENT_SELECTION_LIMIT) + setRecentSelections(nextSelections) + try { + window.localStorage.setItem(recentStorageKey, JSON.stringify(nextSelections)) + } catch { + return + } + }, + [recentSelections, recentStorageKey] + ) + + const handleBlockSelect = useCallback( + (block: SearchBlockItem) => { + rememberSelection({ kind: 'block', id: block.id }) + dispatchSelection(block.type, 'block') + }, + [dispatchSelection, rememberSelection] + ) + + const handleToolSelect = useCallback( + (tool: SearchBlockItem) => { + rememberSelection({ kind: 'tool', id: tool.id }) + dispatchSelection(tool.type, 'tool') + }, + [dispatchSelection, rememberSelection] + ) + + const handleToolOperationSelect = (operation: SearchToolOperationItem) => { + rememberSelection({ kind: 'tool_operation', id: operation.id }) + dispatchSelection(operation.blockType, 'tool_operation', operation.operationId) + } + + const resetResultsViewport = useCallback(() => { + const list = listRef.current + if (!list) return + list.scrollTop = 0 + }, []) + + let firstResultValue = '' + const firstSearchResult = searchResults[0] + const firstRecent = recentResults[0] + const firstPopular = popularBlocks[0] + const firstBrowseBlock = browseBlocks[0] + const firstBrowseTool = browseTools[0] + + if (isSearching) { + if (firstSearchResult) firstResultValue = getPickerResultCommandValue(firstSearchResult) + } else if (firstRecent) { + firstResultValue = getRecentCommandValue(firstRecent) + } else if (firstPopular) { + firstResultValue = getBlockCommandValue(firstPopular, 'block') + } else if (firstBrowseBlock) { + firstResultValue = getBlockCommandValue(firstBrowseBlock, 'block') + } else if (firstBrowseTool) { + firstResultValue = getBlockCommandValue(firstBrowseTool, 'tool') + } + + const resolvedSelectedValue = selectedValue || (search === deferredSearch ? firstResultValue : '') + + const handleSearchChange = (value: string) => { + setSearch(value) + setSelectedValue('') + requestAnimationFrame(resetResultsViewport) + } + + useEffect(() => { + const frame = requestAnimationFrame(resetResultsViewport) + return () => cancelAnimationFrame(frame) + }, [deferredSearch, resetResultsViewport]) + + return ( +
+ +
+ + Add block + + +
+ + + +
+ + + No blocks found. + + {isSearching ? ( + searchResults.length > 0 && ( + + {searchResults.map((result) => ( + { + if (result.kind === 'block') handleBlockSelect(result.item) + else if (result.kind === 'tool') handleToolSelect(result.item) + else handleToolOperationSelect(result.item) + }} + icon={result.item.icon} + bgColor={result.item.bgColor} + showColoredIcon + workflowType={result.kind === 'block' ? result.item.type : undefined} + label={result.item.name} + /> + ))} + + ) + ) : ( + <> + {recentResults.length > 0 && ( + + {recentResults.map((result) => { + if (result.kind === 'tool_operation') { + return ( + handleToolOperationSelect(result.item)} + icon={result.item.icon} + bgColor={result.item.bgColor} + showColoredIcon + label={result.item.name} + /> + ) + } + + return ( + + result.kind === 'block' + ? handleBlockSelect(result.item) + : handleToolSelect(result.item) + } + icon={result.item.icon} + bgColor={result.item.bgColor} + showColoredIcon + workflowType={result.kind === 'block' ? result.item.type : undefined} + label={result.item.name} + /> + ) + })} + + )} + + + + + )} + +
+ + +
+
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx index 38130ca0903..01285f7b558 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useMemo } from 'react' -import { BLOCK_DIMENSIONS, NoteBlockView } from '@sim/workflow-renderer' +import { BLOCK_DIMENSIONS, getNoteBlockHeight, NoteBlockView } from '@sim/workflow-renderer' import type { NodeProps } from 'reactflow' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar' @@ -59,10 +59,9 @@ export const NoteBlock = memo(function NoteBlock({ return typeof storedContent === 'string' ? storedContent : '' }, [data.isPreview, data.subBlockValues, storedValues]) - const isEmpty = content.trim().length === 0 - const userPermissions = useUserPermissionsContext() const canEditWorkflow = userPermissions.canEdit && !data.isWorkflowLocked + const isEmpty = content.trim().length === 0 /** * Calculate deterministic dimensions based on content structure. Uses fixed @@ -71,13 +70,7 @@ export const NoteBlock = memo(function NoteBlock({ useBlockDimensions({ blockId: id, calculateDimensions: () => { - const contentHeight = isEmpty - ? BLOCK_DIMENSIONS.NOTE_MIN_CONTENT_HEIGHT - : BLOCK_DIMENSIONS.NOTE_BASE_CONTENT_HEIGHT - const calculatedHeight = - BLOCK_DIMENSIONS.HEADER_HEIGHT + BLOCK_DIMENSIONS.NOTE_CONTENT_PADDING + contentHeight - - return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: calculatedHeight } + return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: getNoteBlockHeight(isEmpty) } }, dependencies: [isEmpty], }) @@ -90,7 +83,9 @@ export const NoteBlock = memo(function NoteBlock({ hasRing={hasRing} ringStyles={ringStyles} onSelect={handleClick} - actionBar={} + actionBar={ + + } /> ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx index 598207320da..e51bdae9a77 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/deploy.tsx @@ -1,7 +1,7 @@ 'use client' import { useState } from 'react' -import { Button, Tooltip } from '@sim/emcn' +import { Chip } from '@sim/emcn' import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal' import { useChangeDetection, @@ -16,16 +16,10 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface DeployProps { activeWorkflowId: string | null userPermissions: WorkspaceUserPermissions - className?: string disabled?: boolean } -export function Deploy({ - activeWorkflowId, - userPermissions, - className, - disabled = false, -}: DeployProps) { +export function Deploy({ activeWorkflowId, userPermissions, disabled = false }: DeployProps) { const [isModalOpen, setIsModalOpen] = useState(false) const hydrationPhase = useWorkflowRegistry((state) => state.hydration.phase) const isRegistryLoading = hydrationPhase === 'idle' || hydrationPhase === 'state-loading' @@ -81,34 +75,6 @@ export function Deploy({ } } - const getTooltipText = () => { - if (isEmpty) { - return 'Cannot deploy an empty workflow' - } - if (!canDeploy) { - return 'Admin permissions required' - } - if (disabled) { - return 'Workflow is locked' - } - if (isDeploying) { - return 'Deploying...' - } - if (isChangeDetectionSettling) { - return 'Syncing deployment state...' - } - if (deployReadiness.isBlocked && !isDeployed) { - return deployReadiness.tooltip - } - if (changeDetected) { - return 'Update deployment' - } - if (isDeployed) { - return 'Active deployment' - } - return 'Deploy workflow' - } - const getButtonLabel = () => { if (changeDetected) { return 'Update' @@ -121,23 +87,14 @@ export function Deploy({ return ( <> - - - - - - - {getTooltipText()} - + + {getButtonLabel()} +
{(blockConfig || isSubflow) && currentBlock?.type !== 'note' && ( -
-
+ )} {isRenaming ? ( - + + + {isExecuting ? 'Stop' : 'Run'} + +
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx index efdb4153e8f..e260b24ea46 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx @@ -5,7 +5,7 @@ import { hasDiffStatus } from '@/lib/workflows/diff/types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { ActionBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar' import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' -import { useLastRunPath } from '@/stores/execution' +import { useIsCurrentWorkflowExecuting } from '@/stores/execution' import { usePanelEditorStore } from '@/stores/panel' /** @@ -30,20 +30,11 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps state.currentBlockId) const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId) const isFocused = currentBlockId === id - const lastRunPath = useLastRunPath() - const executionStatus = data.executionStatus - const runPathStatus: 'success' | 'error' | undefined = - executionStatus === 'success' || executionStatus === 'error' - ? executionStatus - : isPreview - ? undefined - : lastRunPath.get(id) + const isWorkflowRunning = useIsCurrentWorkflowExecuting() /** * Nesting depth, walking the parent chain so the view can apply nested @@ -71,12 +62,20 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps setCurrentBlockId(id)} - actionBar={} + actionBar={ + + } /> ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts index c6c626f383d..ccdb6130b66 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts @@ -18,6 +18,10 @@ export interface WorkflowBlockProps { isWorkflowLocked?: boolean subBlockValues?: Record 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 0ad551eb5a3..004943efc2e 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 { isChatEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -12,10 +30,12 @@ import { createMcpToolId } from '@/lib/mcp/shared' import { sendMothershipMessage } from '@/lib/mothership/events' import { getProviderIdFromServiceId } from '@/lib/oauth' import { captureEvent } from '@/lib/posthog/client' +import { resolveCanvasBlockPresentation } from '@/lib/workflows/blocks/canvas-presentation' import { calculateWorkflowBlockDimensions } from '@/lib/workflows/blocks/deterministic-dimensions' import { getConditionRows, getRouterRows } from '@/lib/workflows/dynamic-handle-topology' import { getDisplayValue, + hasDisplayableRowValue, resolveDropdownLabel, resolveFilterFieldLabel, resolveSandboxLabel, @@ -71,6 +91,8 @@ 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 { useIsCurrentWorkflowExecuting } from '@/stores/execution' +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' @@ -85,6 +107,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' @@ -171,6 +432,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 } /** @@ -196,7 +461,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 ) } @@ -217,6 +484,8 @@ const SubBlockRow = memo(function SubBlockRow({ displayAdvancedOptions, canonicalIndex, canonicalModeOverrides, + variant, + icon, }: SubBlockRowProps) { const getStringValue = useCallback( (key?: string): string | undefined => { @@ -486,7 +755,13 @@ const SubBlockRow = memo(function SubBlockRow({ const displayValue = maskedValue || hydratedName || (isSelectorType && value ? '-' : value) return ( - + ) }, areSubBlockRowPropsEqual) @@ -513,6 +788,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ runPathStatus, } = useBlockVisual({ blockId: id, data, isPending, isSelected: selected }) + const isWorkflowRunning = useIsCurrentWorkflowExecuting() const currentWorkflowId = (params.workflowId as string) || activeWorkflowId || '' const currentBlock = currentWorkflow.getBlockById(id) @@ -581,6 +857,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) @@ -640,6 +1022,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ ? displayAdvancedMode : displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex) const effectiveTrigger = displayTriggerMode + const canvasPresentation = resolveCanvasBlockPresentation(config, name, rawValues) const visibleSubBlocks = config.subBlocks.filter((block) => { if (block.hidden) return false @@ -679,12 +1062,32 @@ export const WorkflowBlock = memo(function WorkflowBlock({ return false } - if (!block.condition) return true + if (block.condition && !evaluateSubBlockCondition(block.condition, rawValues)) { + return false + } + + if ( + canvasPresentation.usesDefaultTitle && + block.id === canvasPresentation.operationSubBlockId + ) { + return false + } - return evaluateSubBlockCondition(block.condition, rawValues) + return hasDisplayableRowValue(block, rawValues[block.id]) }) - visibleSubBlocks.forEach((block) => { + const chipBlocks = visibleSubBlocks + .filter( + (block) => + canvasPresentation.usesDefaultTitle || block.id !== canvasPresentation.operationSubBlockId + ) + .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]) @@ -701,7 +1104,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ rows.push(currentRow) } - return { rows, stateToUse } + return { rows, stateToUse, chipBlocks, canvasPresentation } }, [ config.subBlocks, config.category, @@ -719,10 +1122,13 @@ export const WorkflowBlock = memo(function WorkflowBlock({ hiddenByReactiveCondition, blockSubBlockValues, activeWorkflowId, + name, ]) const subBlockRows = subBlockRowsData.rows const subBlockState = subBlockRowsData.stateToUse + const chipBlocks = subBlockRowsData.chipBlocks + const canvasPresentation = subBlockRowsData.canvasPresentation const topologySubBlocks = data.isPreview ? (data.blockState?.subBlocks ?? {}) : (currentStoreBlock?.subBlocks ?? {}) @@ -739,13 +1145,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 @@ -772,6 +1173,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. @@ -781,11 +1194,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 } @@ -794,6 +1206,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". @@ -805,9 +1238,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: [ @@ -817,7 +1253,11 @@ export const WorkflowBlock = memo(function WorkflowBlock({ totalRenderedRowCount, conditionRows.length, routerRows.length, + chipBlocks.length, + sentenceData?.lines ?? 0, + Boolean(sentenceData), horizontalHandles, + showsErrorRow, ], }) @@ -839,8 +1279,73 @@ export const WorkflowBlock = memo(function WorkflowBlock({ const wouldCreateConnectionCycle = (source: string, target: string) => wouldCreateCycle(useWorkflowStore.getState().edges, source, target) + const getCanvasRowTitle = (subBlock: SubBlockConfig) => + subBlock.id === canvasPresentation.operationSubBlockId && !canvasPresentation.usesDefaultTitle + ? (canvasPresentation.operationRowTitle ?? subBlock.title ?? subBlock.id) + : (subBlock.title ?? subBlock.id) + 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 : ( <> @@ -848,29 +1353,25 @@ 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 [ , ] }) @@ -892,17 +1395,20 @@ export const WorkflowBlock = memo(function WorkflowBlock({ + ) : undefined } rows={rows} + chips={chips} + typeLabel={canvasPresentation.typeLabel} + 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..c4bc47bbbc6 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 { useIsCurrentWorkflowExecuting, 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,32 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => { })) ) const lastRunEdges = useLastRunEdges() + const isWorkflowRunning = useIsCurrentWorkflowExecuting() + 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 +93,8 @@ const WorkflowEdgeComponent = (props: WorkflowEdgeProps) => { diffStatus={diffStatus} runStatus={runStatus} isPreviewRun={Boolean(previewExecutionStatus)} + isWorkflowRunning={isWorkflowRunning} + isConnectedToSelection={shouldHighlightEdge} /> ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts index 2eac158d9aa..b24c6e15a5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-visual.ts @@ -23,7 +23,7 @@ interface UseBlockVisualProps { /** * Provides visual state and interaction handlers for workflow blocks. - * Computes ring styling based on editor open state, execution, diff, deletion, and run path states. + * Computes ring styling based on editor open state, execution, diff, deletion, and pending states. * Ring is shown only when the editor panel is open for this block, not during selection/dragging. * In preview mode, uses isPreviewSelected for selection highlighting. * @@ -84,7 +84,6 @@ export function useBlockVisual({ isPending: isPreview ? false : isPending, isDeletedBlock: isPreview ? false : isDeletedBlock, diffStatus: isPreview ? undefined : diffStatus, - runPathStatus, isPreviewSelection: isPreview && isPreviewSelected, isSelected: isPreview || isEmbedded ? false : isSelected, }), @@ -94,7 +93,6 @@ export function useBlockVisual({ isPending, isDeletedBlock, diffStatus, - runPathStatus, isPreview, isEmbedded, isPreviewSelected, @@ -106,6 +104,7 @@ export function useBlockVisual({ currentWorkflow, activeWorkflowId, isEnabled, + isExecuting, isLocked, handleClick, hasRing, 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]/hooks/use-workflow-execution.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx index f62015495af..7431d68ebf0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx @@ -9,8 +9,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { DirectUploadErrorMock, executionStoreState, + mockCancel, mockExecute, mockFetch, + mockHandleExecutionCancelledConsole, + mockRequestJson, mockRunUploadStrategy, terminalStoreState, workflowBlocks, @@ -87,8 +90,11 @@ const { return { DirectUploadErrorMock, executionStoreState, + mockCancel: vi.fn(), mockExecute: vi.fn(), mockFetch: vi.fn(), + mockHandleExecutionCancelledConsole: vi.fn(), + mockRequestJson: vi.fn(), mockRunUploadStrategy: vi.fn(), terminalStoreState, workflowBlocks, @@ -105,7 +111,7 @@ vi.mock('next/navigation', () => ({ })) vi.mock('@/lib/api/client/request', () => ({ - requestJson: vi.fn(), + requestJson: mockRequestJson, })) vi.mock('@/lib/api/contracts/workflows', () => ({ @@ -172,7 +178,7 @@ vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-u }), reconcileFinalBlockLogs: vi.fn(), addExecutionErrorConsoleEntry: vi.fn(), - handleExecutionCancelledConsole: vi.fn(), + handleExecutionCancelledConsole: mockHandleExecutionCancelledConsole, handleExecutionErrorConsole: vi.fn(), })) @@ -208,7 +214,7 @@ vi.mock('@/hooks/use-execution-stream', () => { execute: mockExecute, executeFromBlock: vi.fn(), reconnect: vi.fn(), - cancel: vi.fn(), + cancel: mockCancel, cancelExecute: vi.fn(), cancelReconnect: vi.fn(), }), @@ -341,6 +347,44 @@ async function drainStream(value: unknown): Promise { while (!(await reader.read()).done) {} } +describe('useWorkflowExecution cancellation', () => { + beforeEach(() => { + vi.clearAllMocks() + executionStoreState.getCurrentExecutionId.mockReturnValue('execution-1') + mockRequestJson.mockResolvedValue({ success: true }) + }) + + afterEach(() => { + executionStoreState.getCurrentExecutionId.mockReturnValue(null) + }) + + it('stops the local execution state immediately while cancelling the server execution', () => { + const { result, unmount } = renderWorkflowExecutionHook() + + act(() => { + result().handleCancelExecution() + }) + + expect(mockCancel).toHaveBeenCalledWith('workflow-1') + expect(executionStoreState.setCurrentExecutionId).toHaveBeenCalledWith('workflow-1', null) + expect(executionStoreState.setIsExecuting).toHaveBeenCalledWith('workflow-1', false) + expect(executionStoreState.setIsDebugging).toHaveBeenCalledWith('workflow-1', false) + expect(executionStoreState.setActiveBlocks).toHaveBeenCalledWith('workflow-1', expect.any(Set)) + expect(mockHandleExecutionCancelledConsole.mock.calls[0]?.[1]).toEqual({ + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + expect(mockRequestJson).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + params: { id: 'workflow-1', executionId: 'execution-1' }, + }) + ) + + unmount() + }) +}) + describe('useWorkflowExecution attachment uploads', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index 7c708170f14..e05f0363a0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -1828,6 +1828,18 @@ export function useWorkflowExecution() { const storedExecutionId = getCurrentExecutionId(activeWorkflowId) + executionStream.cancel(activeWorkflowId) + currentChatExecutionIdRef.current = null + runFromBlockOwnerRef.current = null + setCurrentExecutionId(activeWorkflowId, null) + setIsExecuting(activeWorkflowId, false) + setIsDebugging(activeWorkflowId, false) + setActiveBlocks(activeWorkflowId, new Set()) + handleExecutionCancelledConsole({ + workflowId: activeWorkflowId, + executionId: storedExecutionId ?? undefined, + }) + if (storedExecutionId) { void requestJson(cancelWorkflowExecutionContract, { params: { id: activeWorkflowId, executionId: storedExecutionId }, @@ -1842,10 +1854,7 @@ export function useWorkflowExecution() { return } - const currentId = getCurrentExecutionId(activeWorkflowId) - if (currentId !== storedExecutionId) return - - logger.info('Workflow execution cancellation confirmed; awaiting terminal event', { + logger.info('Workflow execution cancellation confirmed', { workflowId: activeWorkflowId, executionId: storedExecutionId, }) @@ -1857,12 +1866,6 @@ export function useWorkflowExecution() { error, }) }) - } else { - executionStream.cancel(activeWorkflowId) - currentChatExecutionIdRef.current = null - setIsExecuting(activeWorkflowId, false) - setIsDebugging(activeWorkflowId, false) - setActiveBlocks(activeWorkflowId, new Set()) } if (isDebugging) { @@ -1875,8 +1878,10 @@ export function useWorkflowExecution() { setIsExecuting, setIsDebugging, setActiveBlocks, + setCurrentExecutionId, activeWorkflowId, getCurrentExecutionId, + handleExecutionCancelledConsole, ]) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.test.ts new file mode 100644 index 00000000000..53b68a0a19d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { getBlockRingStyles } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils' + +const IDLE_OPTIONS = { + isExecuting: false, + isEditorOpen: false, + isPending: false, + isDeletedBlock: false, + diffStatus: null, + isPreviewSelection: false, + isSelected: false, +} as const + +describe('getBlockRingStyles', () => { + it('does not render an outline for execution alone', () => { + expect(getBlockRingStyles({ ...IDLE_OPTIONS, isExecuting: true })).toEqual({ + hasRing: false, + ringClassName: '', + }) + }) + + it('preserves the selection ring while a selected block executes', () => { + const result = getBlockRingStyles({ + ...IDLE_OPTIONS, + isExecuting: true, + isSelected: true, + }) + + expect(result.hasRing).toBe(true) + expect(result.ringClassName).toContain('ring-[var(--text-secondary)]') + expect(result.ringClassName).not.toContain('animate-ring-pulse') + expect(result.ringClassName).not.toContain('ring-[var(--border-success)]') + }) + + it('preserves non-execution status rings while idle', () => { + const result = getBlockRingStyles({ + ...IDLE_OPTIONS, + diffStatus: 'new', + }) + + expect(result.hasRing).toBe(true) + expect(result.ringClassName).toContain('ring-[var(--brand-accent)]') + }) +}) 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..26871469f6b 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 @@ -2,17 +2,14 @@ import { cn } from '@sim/emcn' export type BlockDiffStatus = 'new' | 'edited' | null | undefined -export type BlockRunPathStatus = 'success' | 'error' | undefined - interface BlockRingOptions { - /** Whether the block is executing (shows green pulsing ring) */ + /** Whether the block is executing (suppresses stale status rings) */ isExecuting: boolean /** Whether the editor panel is open for this block (shows blue ring) */ isEditorOpen: boolean isPending: boolean isDeletedBlock: boolean diffStatus: BlockDiffStatus - runPathStatus: BlockRunPathStatus isPreviewSelection?: boolean /** Whether the block is selected via shift-click or selection box (shows blue ring) */ isSelected?: boolean @@ -20,7 +17,7 @@ interface BlockRingOptions { /** * Derives visual ring visibility and class names for workflow blocks - * based on editor open state, execution, diff, deletion, and run-path states. + * based on editor open state, execution, diff, deletion, and pending states. */ export function getBlockRingStyles(options: BlockRingOptions): { hasRing: boolean @@ -32,76 +29,42 @@ export function getBlockRingStyles(options: BlockRingOptions): { isPending, isDeletedBlock, diffStatus, - runPathStatus, isPreviewSelection, isSelected, } = options - const hasRing = - isExecuting || - isEditorOpen || - isSelected || - isPending || - diffStatus === 'new' || - diffStatus === 'edited' || - isDeletedBlock || - !!runPathStatus + const isSelectionHighlighted = isEditorOpen || isSelected || isPreviewSelection + const hasStatusRing = + !isExecuting && (isPending || diffStatus === 'new' || diffStatus === 'edited' || isDeletedBlock) + const hasRing = Boolean(isSelectionHighlighted || hasStatusRing) 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 - !isExecuting && - (isEditorOpen || isSelected || isPreviewSelection) && - 'ring-[1.75px] ring-[var(--brand-secondary)]', + // Editor open, selected, or preview selection: neutral highlight ring, + // matching the selection-highlighted edge color + isSelectionHighlighted && 'ring-[1.5px] ring-[var(--text-secondary)]', // Non-active states use standard ring utilities - !isExecuting && - !isEditorOpen && - !isSelected && - !isPreviewSelection && - hasRing && - 'ring-[1.75px]', + !isSelectionHighlighted && hasStatusRing && 'ring-[1.5px]', // Pending state: warning ring - !isExecuting && !isEditorOpen && !isSelected && isPending && 'ring-[var(--warning)]', + !isSelectionHighlighted && !isExecuting && isPending && 'ring-[var(--warning)]', // Deleted state (highest priority after active/pending) - !isExecuting && - !isEditorOpen && - !isSelected && + !isSelectionHighlighted && + !isExecuting && !isPending && isDeletedBlock && 'ring-[var(--text-error)]', // Diff states - !isExecuting && - !isEditorOpen && - !isSelected && + !isSelectionHighlighted && + !isExecuting && !isPending && !isDeletedBlock && diffStatus === 'new' && 'ring-[var(--brand-accent)]', - !isExecuting && - !isEditorOpen && - !isSelected && + !isSelectionHighlighted && + !isExecuting && !isPending && !isDeletedBlock && diffStatus === 'edited' && - 'ring-[var(--warning)]', - // Run path states (lowest priority - only show if no other states active) - !isExecuting && - !isEditorOpen && - !isSelected && - !isPending && - !isDeletedBlock && - !diffStatus && - runPathStatus === 'success' && - 'ring-[var(--border-success)]', - !isExecuting && - !isEditorOpen && - !isSelected && - !isPending && - !isDeletedBlock && - !diffStatus && - runPathStatus === 'error' && - 'ring-[var(--text-error)]' + 'ring-[var(--warning)]' ) return { hasRing, ringClassName } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts index 30f9edd2cb0..d432a2a9126 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.test.ts @@ -2,7 +2,10 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { isPositionalTriggerBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers' +import { + isPositionalTriggerBlock, + shouldHighlightContainerDropTarget, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers' describe('isPositionalTriggerBlock', () => { it('returns true for a top-level block with no incoming edges', () => { @@ -60,3 +63,14 @@ describe('isPositionalTriggerBlock', () => { expect(isPositionalTriggerBlock(pastedBlock, edges)).toBe(false) }) }) + +describe('shouldHighlightContainerDropTarget', () => { + it('does not highlight the loop while a nested block moves within it', () => { + expect(shouldHighlightContainerDropTarget('loop-1', 'loop-1')).toBe(false) + }) + + it('highlights a different container as a genuine re-parent target', () => { + expect(shouldHighlightContainerDropTarget('loop-1', 'loop-2')).toBe(true) + expect(shouldHighlightContainerDropTarget(null, 'loop-1')).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts index 2fa772f78bb..ad9b690f111 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers.ts @@ -4,6 +4,8 @@ import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils' import type { BlockState } from '@/stores/workflows/workflow/types' +export const SUBFLOW_DROP_TARGET_CLASS = 'subflow-node-drop-target' + /** * Collects all descendant block IDs for container blocks (loop/parallel) in the given set. * Used to treat a nested subflow as one unit when computing boundary edges (e.g. remove-from-subflow). @@ -107,13 +109,25 @@ export function isPositionalTriggerBlock( return !edges.some((edge) => edge.target === block.id) } +/** + * Returns whether a container should be emphasized as a new drop target. + * Moving within the block's existing container is a position change, not a + * re-parent operation, so it must not activate the container highlight. + */ +export function shouldHighlightContainerDropTarget( + currentParentId: string | null | undefined, + targetContainerId: string +): boolean { + return currentParentId !== targetContainerId +} + /** * Clears drag highlight classes and resets cursor state. * Used when drag operations end or are cancelled. */ export function clearDragHighlights(): void { - document.querySelectorAll('.loop-node-drag-over, .parallel-node-drag-over').forEach((el) => { - el.classList.remove('loop-node-drag-over', 'parallel-node-drag-over') + document.querySelectorAll(`.${SUBFLOW_DROP_TARGET_CLASS}`).forEach((el) => { + el.classList.remove(SUBFLOW_DROP_TARGET_CLASS) }) document.body.style.cursor = '' } 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..26aa09fde74 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow-constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow-constants.ts @@ -1,6 +1,7 @@ import dynamic from 'next/dynamic' import type { EdgeTypes, NodeTypes } from 'reactflow' import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components' +import { ConnectionBlockSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector' import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' @@ -22,6 +23,7 @@ const NoteBlock = dynamic( /** Custom node types for ReactFlow. */ export const nodeTypes: NodeTypes = { + connectionBlockSelector: ConnectionBlockSelector, workflowBlock: WorkflowBlock, noteBlock: NoteBlock, subflowNode: SubflowNodeComponent, @@ -39,8 +41,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..a5c9b021659 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,18 @@ 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, + getPositionedTargetHandleId, + 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' @@ -34,6 +46,11 @@ import { } from '@/app/workspace/[workspaceId]/w/[workflowId]/components' import { BlockMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/block-menu' import { CanvasMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/canvas-menu' +import { + CONNECTION_BLOCK_SELECTOR_DIMENSIONS, + CONNECTION_BLOCK_SELECTOR_NODE_ID, + type ConnectionBlockSelectorData, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/connection-block-selector/connection-block-selector' import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors' import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index' import { WorkflowSearchReplace } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace' @@ -64,6 +81,8 @@ import { isInEditableElement, isPositionalTriggerBlock, resolveSelectionConflicts, + SUBFLOW_DROP_TARGET_CLASS, + shouldHighlightContainerDropTarget, validateTriggerPaste, } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { @@ -116,6 +135,16 @@ const LazyChat = lazy(() => const logger = createLogger('Workflow') const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 } +const CONNECTION_BLOCK_SELECTOR_FOCUS_DURATION_MS = 300 +const SUBFLOW_FOCUS_STABLE_FRAMES = 2 +const SUBFLOW_FOCUS_MIN_WAIT_FRAMES = 4 +const SUBFLOW_FOCUS_MAX_WAIT_FRAMES = 18 +const SUBFLOW_FOCUS_LAYOUT_TOLERANCE_PX = 0.5 +const SUBFLOW_FOCUS_PADDING = 0.08 +const CONNECTION_LINE_STYLE = { + stroke: 'var(--connection-line-stroke)', + strokeWidth: 2, +} /** * Calculates the offset to paste blocks at viewport center @@ -232,10 +261,17 @@ 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 [pendingConnect, setPendingConnect] = useState(null) + const hasPointerDownSinceSelectorOpenedRef = useRef(false) + const closeConnectionBlockSelector = useCallback(() => { + hasPointerDownSinceSelectorOpenedRef.current = false + setPendingConnect(null) + }, []) const canvasContainerRef = useRef(null) const embeddedFitFrameRef = useRef(null) const hasCompletedInitialEmbeddedFitRef = useRef(false) + const initializedViewportWorkflowIdRef = useRef(null) + const userFocusedWorkflowIdRef = useRef(null) const canvasMode = useCanvasModeStore((state) => state.mode) const isHandMode = embedded ? true : canvasMode === 'hand' const { handleCanvasMouseDown, selectionProps } = useShiftSelectionLock({ isHandMode }) @@ -304,6 +340,10 @@ const WorkflowContent = React.memo( })) ) + useEffect(() => { + userFocusedWorkflowIdRef.current = null + }, [activeWorkflowId, workflowIdParam]) + const currentWorkflow = useCurrentWorkflow() // Undo/redo availability for context menu @@ -464,19 +504,14 @@ const WorkflowContent = React.memo( ) /** Applies highlight styling to a container node during drag operations. */ - const highlightContainerNode = useCallback( - (containerId: string, containerKind: 'loop' | 'parallel') => { - clearDragHighlights() - const containerElement = document.querySelector(`[data-id="${containerId}"]`) - if (containerElement) { - containerElement.classList.add( - containerKind === 'loop' ? 'loop-node-drag-over' : 'parallel-node-drag-over' - ) - document.body.style.cursor = 'copy' - } - }, - [] - ) + const highlightContainerNode = useCallback((containerId: string) => { + clearDragHighlights() + const containerElement = document.querySelector(`[data-id="${containerId}"]`) + if (containerElement) { + containerElement.classList.add(SUBFLOW_DROP_TARGET_CLASS) + document.body.style.cursor = 'copy' + } + }, []) const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null, { embedded, @@ -526,7 +561,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 +636,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 +672,8 @@ const WorkflowContent = React.memo( collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, collaborativeBatchToggleBlockEnabled, - collaborativeBatchToggleBlockHandles, collaborativeBatchToggleLocked, + collaborativeSetBlockErrorEnabled, undo, redo, } = useCollaborativeWorkflow() @@ -767,6 +812,19 @@ const WorkflowContent = React.memo( ] ) + /** + * Block queued to be centered once its node mounts and has been measured. + * Creation is asynchronous — the store write, the node mount, and the + * dimension measurement each land on a later frame — so `addBlock` records + * the id and the effect below performs the camera move. + */ + const pendingFocusBlockIdRef = useRef(null) + /** + * Mirrors displayNodes for the run-follow subscription, which must read + * current node positions without re-subscribing on every node change. + */ + const displayNodesRef = useRef([]) + const addBlock = useCallback( ( id: string, @@ -782,6 +840,7 @@ const WorkflowContent = React.memo( ) => { setPendingSelection([id]) setSelectedEdges(new Map()) + pendingFocusBlockIdRef.current = id const blockData: Record = { ...(data || {}) } if (parentId) blockData.parentId = parentId @@ -843,15 +902,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 +1266,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) @@ -2037,13 +2082,16 @@ const WorkflowContent = React.memo( if (typeof type !== 'string' || !type) return if (type === 'connectionBlock') return - // Complete an edge drag-release: only a genuine palette selection carries - // `pendingConnect` (other add-block dispatchers — toolbar, sidebar, command - // list — don't), so its presence is the signal. Delegating to - // handleToolbarDrop with the drag source gives container-aware placement AND - // an edge from the released handle that respects container boundaries. + /** + * A genuine edge drag-release carries `pendingConnect`. Delegating to + * `handleToolbarDrop` preserves container-aware placement and connects + * from the exact released handle. + */ if (pendingConnect) { - // screenToFlowPosition subtracts the pane rect internally — pass raw client coords. + const blockHeight = + type === 'loop' || type === 'parallel' + ? CONTAINER_DIMENSIONS.DEFAULT_HEIGHT + : estimateBlockDimensions(type).height handleToolbarDrop( { type, @@ -2051,7 +2099,10 @@ const WorkflowContent = React.memo( presetOperation: typeof presetOperation === 'string' ? presetOperation : undefined, forcedSource: pendingConnect.source, }, - screenToFlowPosition({ x: pendingConnect.screenX, y: pendingConnect.screenY }) + { + x: pendingConnect.position.x, + y: pendingConnect.position.y - blockHeight / 2, + } ) return } @@ -2333,7 +2384,7 @@ const WorkflowContent = React.memo( if (containerNode?.type === 'subflowNode') { const kind = (containerNode.data as SubflowNodeData)?.kind if (kind === 'loop' || kind === 'parallel') { - highlightContainerNode(containerInfo.loopId, kind) + highlightContainerNode(containerInfo.loopId) } } } else { @@ -2638,6 +2689,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,10 +2712,13 @@ const WorkflowContent = React.memo( getBlockConfig, embedded, workflowReadOnly, + collaborativeSetBlockErrorEnabled, + collaborativeBatchRemoveEdges, ]) // Local state for nodes - allows smooth drag without store updates on every frame const [displayNodes, setDisplayNodes] = useState([]) + displayNodesRef.current = displayNodes const [lastInteractedNodeId, setLastInteractedNodeId] = useState(null) const selectedNodeIds = useMemo( @@ -2913,18 +2969,45 @@ const WorkflowContent = React.memo( /** Handles node changes - applies changes and resolves parent-child selection conflicts. */ const onNodesChange = useCallback( (changes: NodeChange[]) => { - const hasSelectionChange = changes.some((c) => c.type === 'select') + const selectorPositionChange = [...changes] + .reverse() + .find( + (change) => + change.type === 'position' && + change.id === CONNECTION_BLOCK_SELECTOR_NODE_ID && + change.position + ) + + if (selectorPositionChange?.type === 'position' && selectorPositionChange.position) { + const { position } = selectorPositionChange + setPendingConnect((current) => + current + ? { + ...current, + position: { + x: position.x, + y: position.y + CONNECTION_BLOCK_SELECTOR_DIMENSIONS.height / 2, + }, + } + : current + ) + } + + const workflowChanges = changes.filter( + (change) => !('id' in change) || change.id !== CONNECTION_BLOCK_SELECTOR_NODE_ID + ) + const hasSelectionChange = workflowChanges.some((c) => c.type === 'select') setDisplayNodes((currentNodes) => { // Filter out cross-context selection changes before applying so that // nodes at a different nesting level never appear selected, even for // a single frame. - let changesToApply = changes + let changesToApply = workflowChanges if (hasSelectionChange) { const currentlySelected = currentNodes.filter((n) => n.selected) // Only filter on additive multi-select (shift-click), not replacement // clicks. A replacement click includes deselections of currently selected // nodes; a shift-click only adds selections. - const isReplacementClick = changes.some( + const isReplacementClick = workflowChanges.some( (c) => c.type === 'select' && 'selected' in c && @@ -2933,7 +3016,7 @@ const WorkflowContent = React.memo( ) if (!isReplacementClick && currentlySelected.length > 0) { const selectionContext = getNodeSelectionContextId(currentlySelected[0], blocks) - changesToApply = changes.filter((c) => { + changesToApply = workflowChanges.filter((c) => { if (c.type !== 'select' || !('selected' in c) || !c.selected) return true const node = currentNodes.find((n) => n.id === c.id) if (!node) return true @@ -2962,7 +3045,7 @@ const WorkflowContent = React.memo( getDragStartPosition() !== null || multiNodeDragStartRef.current.size > 0 const keyboardPositionUpdates: Array<{ id: string; position: { x: number; y: number } }> = [] - for (const change of changes) { + for (const change of workflowChanges) { if ( change.type === 'position' && !change.dragging && @@ -3085,20 +3168,41 @@ 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) => { + useSearchModalStore.getState().close() + closeConnectionBlockSelector() + 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 + }, + [closeConnectionBlockSelector, 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, + blocks[connection.source]?.type + ) + ), + 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 +3210,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 +3220,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 +3231,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 +3266,7 @@ const WorkflowContent = React.memo( // Add appropriate metadata for container context addEdge({ - ...connection, + ...normalizedConnection, id: edgeId, type: 'workflowEdge', data: isInsideContainer @@ -3187,7 +3291,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,36 +3309,89 @@ 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, blocks[source.nodeId]?.type) ?? 'source' // Create connection if valid target found (handle-to-body case) if (targetNode && targetNode.id !== source.nodeId) { + /* + * 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) + } + /* + * Always source→target. Inputs never originate a drag: the `target` + * handle sets `isConnectableStart={false}` and the positioned side + * anchors are `isConnectable={false}`, so React Flow only ever + * reports an output handle here. Dragging over an input knob starts + * from the cursor swell's temporary source handle instead. + */ + const targetHandle = + !dropSide || dropSide === 'left' ? 'target' : getPositionedTargetHandleId(dropSide) onConnect({ source: source.nodeId, - sourceHandle: source.handleId, + sourceHandle, target: targetNode.id, - targetHandle: 'target', + 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 }, - screenX: clientPos.clientX, - screenY: clientPos.clientY, - }, + const canvasBounds = canvasContainerRef.current?.getBoundingClientRect() + const zoom = reactFlowInstance.getViewport().zoom + const margin = 12 + const selectorWidth = CONNECTION_BLOCK_SELECTOR_DIMENSIONS.width * zoom + const selectorHalfHeight = (CONNECTION_BLOCK_SELECTOR_DIMENSIONS.height * zoom) / 2 + const minScreenX = (canvasBounds?.left ?? 0) + margin + const maxScreenX = (canvasBounds?.right ?? clientPos.clientX) - selectorWidth - margin + const minScreenY = (canvasBounds?.top ?? 0) + selectorHalfHeight + margin + const maxScreenY = + (canvasBounds?.bottom ?? clientPos.clientY) - selectorHalfHeight - margin + const position = screenToFlowPosition({ + x: + maxScreenX >= minScreenX + ? Math.min(Math.max(clientPos.clientX, minScreenX), maxScreenX) + : clientPos.clientX, + y: + maxScreenY >= minScreenY + ? Math.min(Math.max(clientPos.clientY, minScreenY), maxScreenY) + : clientPos.clientY, + }) + hasPointerDownSinceSelectorOpenedRef.current = false + const nextPendingConnect: PendingConnect = { + source: { nodeId: source.nodeId, handleId: sourceHandle }, + position, + } + setPendingConnect(nextPendingConnect) + requestAnimationFrame(() => { + const { zoom: currentZoom } = reactFlowInstance.getViewport() + void reactFlowInstance.setCenter( + nextPendingConnect.position.x + CONNECTION_BLOCK_SELECTOR_DIMENSIONS.width / 2, + nextPendingConnect.position.y, + { + zoom: currentZoom, + duration: CONNECTION_BLOCK_SELECTOR_FOCUS_DURATION_MS, + } + ) }) } connectionSourceRef.current = null }, - [findNodeAtScreenPosition, onConnect] + [findNodeAtScreenPosition, onConnect, blocks, reactFlowInstance, screenToFlowPosition] ) /** Handles node drag to detect container intersections and update highlighting. */ const onNodeDrag = useCallback( (_event: React.MouseEvent, node: any) => { + if (node.id === CONNECTION_BLOCK_SELECTOR_NODE_ID) return + // Note: We don't emit position updates during drag to avoid flooding socket events. // The final position is sent in onNodeDragStop for collaborative updates. @@ -3347,10 +3505,15 @@ const WorkflowContent = React.memo( if (bestContainerMatch) { setPotentialParentId(bestContainerMatch.container.id) - // Add highlight class and change cursor - const kind = (bestContainerMatch.container.data as SubflowNodeData)?.kind - if (kind === 'loop' || kind === 'parallel') { - highlightContainerNode(bestContainerMatch.container.id, kind) + if ( + !shouldHighlightContainerDropTarget(currentParentId, bestContainerMatch.container.id) + ) { + clearDragHighlights() + } else { + const kind = (bestContainerMatch.container.data as SubflowNodeData)?.kind + if (kind === 'loop' || kind === 'parallel') { + highlightContainerNode(bestContainerMatch.container.id) + } } } else { clearDragHighlights() @@ -3380,6 +3543,8 @@ const WorkflowContent = React.memo( /** Captures initial parent ID and position when drag starts. */ const onNodeDragStart = useCallback( (_event: React.MouseEvent, node: any) => { + if (node.id === CONNECTION_BLOCK_SELECTOR_NODE_ID) return + // Note: Protected blocks are already non-draggable via the `draggable` node property // Store the original parent ID when starting to drag @@ -3446,6 +3611,8 @@ const WorkflowContent = React.memo( /** Handles node drag stop to establish parent-child relationships. */ const onNodeDragStop = useCallback( (_event: React.MouseEvent, node: any) => { + if (node.id === CONNECTION_BLOCK_SELECTOR_NODE_ID) return + clearDragHighlights() // Get all selected nodes to update their positions too @@ -3723,6 +3890,11 @@ const WorkflowContent = React.memo( // Filter out nodes that can't be placed in containers const eligibleNodes = nodes.filter(canNodeEnterContainer) + const currentParentIds = new Set( + eligibleNodes.map((node) => blocks[node.id]?.data?.parentId ?? null) + ) + const sharedCurrentParentId = + currentParentIds.size === 1 ? currentParentIds.values().next().value : null // If no eligible nodes, clear any potential parent if (eligibleNodes.length === 0) { @@ -3800,16 +3972,22 @@ const WorkflowContent = React.memo( }) const bestMatch = sortedContainers[0] + const shouldHighlightContainer = shouldHighlightContainerDropTarget( + sharedCurrentParentId, + bestMatch.container.id + ) if (bestMatch.container.id !== potentialParentId) { setPotentialParentId(bestMatch.container.id) - // Add highlight - const kind = (bestMatch.container.data as SubflowNodeData)?.kind - if (kind === 'loop' || kind === 'parallel') { - highlightContainerNode(bestMatch.container.id, kind) + if (shouldHighlightContainer) { + const kind = (bestMatch.container.data as SubflowNodeData)?.kind + if (kind === 'loop' || kind === 'parallel') { + highlightContainerNode(bestMatch.container.id) + } } } + if (!shouldHighlightContainer) clearDragHighlights() } else if (potentialParentId) { clearDragHighlights() setPotentialParentId(null) @@ -3817,6 +3995,7 @@ const WorkflowContent = React.memo( }, [ canNodeEnterContainer, + blocks, getNodes, potentialParentId, getNodeAbsolutePosition, @@ -3858,7 +4037,174 @@ const WorkflowContent = React.memo( const onPaneClick = useCallback(() => { setSelectedEdges(new Map()) usePanelEditorStore.getState().clearCurrentBlock() - }, []) + if (pendingConnect && hasPointerDownSinceSelectorOpenedRef.current) { + closeConnectionBlockSelector() + } + }, [closeConnectionBlockSelector, pendingConnect]) + + const handleCanvasPointerDownCapture = () => { + if (pendingConnect) { + hasPointerDownSinceSelectorOpenedRef.current = true + } + } + + /** + * Animates the camera so the given block centers in the canvas frame. + * Regular cards defer by two frames and retain close zoom. Subflows wait + * for the editor panel layout to settle, then fit the full container into + * the centered canvas frame so large loops are not cropped. + */ + const focusBlockInView = useCallback( + (node: Node, onFocusStart?: () => void, durationMs = 500) => { + const centerNode = () => { + const currentNodes = getNodes() + const mountedNode = currentNodes.find((candidate) => candidate.id === node.id) ?? node + const getAbsolutePosition = (candidate: Node) => + candidate.parentId + ? (candidate.positionAbsolute ?? getNodeAbsolutePosition(candidate.id)) + : candidate.position + const getFocusDimensions = (candidate: Node) => { + const subflowData = + candidate.type === 'subflowNode' ? (candidate.data as SubflowNodeData) : undefined + const declaredWidth = subflowData?.width + const declaredHeight = subflowData?.height + const width = + typeof declaredWidth === 'number' && declaredWidth > 0 + ? declaredWidth + : typeof candidate.width === 'number' && candidate.width > 0 + ? candidate.width + : candidate.type === 'subflowNode' + ? CONTAINER_DIMENSIONS.DEFAULT_WIDTH + : 250 + const height = + typeof declaredHeight === 'number' && declaredHeight > 0 + ? declaredHeight + : typeof candidate.height === 'number' && candidate.height > 0 + ? candidate.height + : candidate.type === 'subflowNode' + ? CONTAINER_DIMENSIONS.DEFAULT_HEIGHT + : 100 + return { width, height } + } + + if (mountedNode.type === 'subflowNode') { + const focusCandidates = currentNodes.some( + (candidate) => candidate.id === mountedNode.id + ) + ? currentNodes + : [...currentNodes, mountedNode] + const nodesById = new Map(focusCandidates.map((candidate) => [candidate.id, candidate])) + const focusNodes = focusCandidates + .filter((candidate) => { + let currentId: string | undefined = candidate.id + const visitedIds = new Set() + while (currentId && !visitedIds.has(currentId)) { + if (currentId === mountedNode.id) return true + visitedIds.add(currentId) + currentId = nodesById.get(currentId)?.parentId + } + return false + }) + .map((candidate) => ({ + ...candidate, + position: getAbsolutePosition(candidate), + ...getFocusDimensions(candidate), + })) + + fitViewToBounds({ + nodes: focusNodes, + padding: SUBFLOW_FOCUS_PADDING, + maxZoom: 1, + minZoom: 0.1, + duration: durationMs, + }) + onFocusStart?.() + return + } + + const position = getAbsolutePosition(mountedNode) + const { width: nodeWidth, height: nodeHeight } = getFocusDimensions(mountedNode) + const currentZoom = reactFlowInstance.getViewport().zoom + const targetZoom = Math.max(currentZoom, 1) + reactFlowInstance.setCenter(position.x + nodeWidth / 2, position.y + nodeHeight / 2, { + zoom: targetZoom, + duration: durationMs, + }) + onFocusStart?.() + } + + if (node.type !== 'subflowNode') { + requestAnimationFrame(() => requestAnimationFrame(centerNode)) + return + } + + let previousWidth = canvasContainerRef.current?.getBoundingClientRect().width + let stableFrames = 0 + let elapsedFrames = 0 + let remainingFrames = SUBFLOW_FOCUS_MAX_WAIT_FRAMES + + const waitForSettledCanvas = () => { + requestAnimationFrame(() => { + const currentWidth = canvasContainerRef.current?.getBoundingClientRect().width + if ( + currentWidth !== undefined && + previousWidth !== undefined && + Math.abs(currentWidth - previousWidth) <= SUBFLOW_FOCUS_LAYOUT_TOLERANCE_PX + ) { + stableFrames += 1 + } else { + stableFrames = 0 + } + + previousWidth = currentWidth + elapsedFrames += 1 + remainingFrames -= 1 + if ( + (elapsedFrames >= SUBFLOW_FOCUS_MIN_WAIT_FRAMES && + stableFrames >= SUBFLOW_FOCUS_STABLE_FRAMES) || + remainingFrames <= 0 + ) { + centerNode() + return + } + waitForSettledCanvas() + }) + } + + waitForSettledCanvas() + }, + [fitViewToBounds, getNodeAbsolutePosition, getNodes, reactFlowInstance] + ) + + /** + * Centers a newly created block once its node has mounted and been + * measured. A card added from a drag-release, the block menu, or the + * toolbar can land outside the viewport (or under the editor panel), so + * the camera follows it the same way it follows a click. + * + * Waits for real dimensions: `focusBlockInView` centers on the card's + * midpoint, and an unmeasured node reports no size, which would center the + * camera on its top-left corner instead. + */ + useEffect(() => { + const pendingId = pendingFocusBlockIdRef.current + if (!pendingId) return + + const node = displayNodes.find((candidate) => candidate.id === pendingId) + if (!node) return + if ( + typeof node.width !== 'number' || + typeof node.height !== 'number' || + node.width <= 0 || + node.height <= 0 + ) { + return + } + + pendingFocusBlockIdRef.current = null + if (embedded) return + focusBlockInView(node) + }, [displayNodes, embedded, focusBlockInView]) /** * Handles node click to select the node in ReactFlow. @@ -3893,14 +4239,95 @@ 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. Plain clicks focus both regular cards and subflow + * containers; multi-select keeps the camera still. + * onNodeClick never fires after a drag. + */ + if ( + !embedded && + !isMultiSelect && + (node.type === 'workflowBlock' || node.type === 'subflowNode') + ) { + userFocusedWorkflowIdRef.current = activeWorkflowId ?? workflowIdParam + focusBlockInView(node) + } }, - [blocks, getNodes] + [activeWorkflowId, blocks, getNodes, embedded, focusBlockInView, workflowIdParam] ) + /** + * 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) => { event.stopPropagation() // Prevent bubbling + if (edge.id === `${CONNECTION_BLOCK_SELECTOR_NODE_ID}-edge`) return const contextId = `${edge.id}${(() => { const selectionContextId = getEdgeSelectionContextId(edge, getNodes(), blocks) @@ -3964,7 +4391,7 @@ const WorkflowContent = React.memo( // Child blocks inside containers already carry zIndex 1000 and are bumped by // +10 when selected so they stay above their sibling child blocks. const nodesForRender = useMemo(() => { - return displayNodes.map((node) => { + const elevatedNodes = displayNodes.map((node) => { if (node.type === 'subflowNode') return node const base = node.zIndex ?? 21 const target = node.selected @@ -3975,44 +4402,67 @@ const WorkflowContent = React.memo( if (target === (node.zIndex ?? 21)) return node return { ...node, zIndex: target } }) - }, [displayNodes, lastInteractedNodeId]) + + if (!pendingConnect) return elevatedNodes + + return [ + ...elevatedNodes, + { + id: CONNECTION_BLOCK_SELECTOR_NODE_ID, + type: 'connectionBlockSelector', + position: { + x: pendingConnect.position.x, + y: pendingConnect.position.y - CONNECTION_BLOCK_SELECTOR_DIMENSIONS.height / 2, + }, + data: { + pendingConnect, + onClose: closeConnectionBlockSelector, + }, + width: CONNECTION_BLOCK_SELECTOR_DIMENSIONS.width, + height: CONNECTION_BLOCK_SELECTOR_DIMENSIONS.height, + zIndex: 2000, + dragHandle: '.workflow-drag-handle', + draggable: true, + selectable: false, + connectable: false, + deletable: false, + focusable: false, + } satisfies Node, + ] + }, [closeConnectionBlockSelector, displayNodes, lastInteractedNodeId, pendingConnect]) /** 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 +4470,36 @@ const WorkflowContent = React.memo( }, } }) - }, [ - edgesForDisplay, - displayNodes, - selectedNodeIds, - selectedEdges, - handleEdgeDelete, - lastInteractedNodeId, - ]) + }, [edgesForDisplay, displayNodes, selectedNodeIds, selectedEdges, handleEdgeDelete]) + + const edgesForRender = useMemo(() => { + if (!pendingConnect) return edgesWithSelection + + const sourceParentId = blocks[pendingConnect.source.nodeId]?.data?.parentId + const sourceParentNode = sourceParentId + ? displayNodes.find((node) => node.id === sourceParentId) + : null + + return [ + ...edgesWithSelection, + { + id: `${CONNECTION_BLOCK_SELECTOR_NODE_ID}-edge`, + source: pendingConnect.source.nodeId, + sourceHandle: pendingConnect.source.handleId, + target: CONNECTION_BLOCK_SELECTOR_NODE_ID, + targetHandle: 'target', + type: 'workflowEdge', + zIndex: sourceParentNode ? (sourceParentNode.zIndex ?? 0) + 1 : 0, + focusable: false, + deletable: false, + reconnectable: false, + data: { + isConnectedToSelection: true, + sourceHandle: pendingConnect.source.handleId, + }, + } satisfies Edge, + ] + }, [blocks, displayNodes, edgesWithSelection, pendingConnect]) /** Handles Delete/Backspace to remove selected edges or blocks. */ useEffect(() => { @@ -4138,7 +4610,11 @@ const WorkflowContent = React.memo( return (
-
+
{!isWorkflowReady && (
{ + if (userFocusedWorkflowIdRef.current === viewportWorkflowId) { + setIsCanvasReady(true) + return + } instance.fitView(reactFlowFitViewOptions) setIsCanvasReady(true) }) @@ -4197,7 +4687,7 @@ const WorkflowContent = React.memo( panOnScroll defaultEdgeOptions={defaultEdgeOptions} proOptions={reactFlowProOptions} - connectionLineStyle={connectionLineStyle} + connectionLineStyle={CONNECTION_LINE_STYLE} connectionLineType={ConnectionLineType.SmoothStep} onPaneClick={onPaneClick} onEdgeClick={embedded ? undefined : onEdgeClick} @@ -4214,6 +4704,7 @@ const WorkflowContent = React.memo( selectionKeyCode={embedded ? null : selectionProps.selectionKeyCode} multiSelectionKeyCode={embedded ? null : ['Meta', 'Control', 'Shift']} nodesConnectable={!embedded && effectivePermissions.canEdit} + connectOnClick={false} nodesDraggable={!embedded && effectivePermissions.canEdit} draggable={false} noWheelClassName='allow-scroll' @@ -4265,7 +4756,6 @@ const WorkflowContent = React.memo( onDuplicate={handleContextDuplicate} onDelete={handleContextDelete} onToggleEnabled={handleContextToggleEnabled} - onToggleHandles={handleContextToggleHandles} onRemoveFromSubflow={handleContextRemoveFromSubflow} onOpenEditor={handleContextOpenEditor} onRename={handleContextRename} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index 9c9b9f8a933..e06183909e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -1,10 +1,18 @@ 'use client' import { type CSSProperties, memo, useMemo } from 'react' -import { HANDLE_POSITIONS } from '@sim/workflow-renderer' +import { HANDLE_POSITIONS, humanizeBlockName, WorkflowTypeTag } from '@sim/workflow-renderer' +import { + getPositionedSourceHandleId, + getPositionedTargetHandleId, + POSITIONED_SOURCE_HANDLE_SIDES, + type PositionedSourceHandleSide, +} from '@sim/workflow-types/workflow' import { Handle, type NodeProps, Position } from 'reactflow' +import { resolveCanvasBlockPresentation } from '@/lib/workflows/blocks/canvas-presentation' import { getDisplayValue, + hasDisplayableRowValue, resolveDropdownLabel, resolveSkillsLabel, resolveToolsLabel, @@ -20,7 +28,6 @@ import { isToolInputOnlySubBlock, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' -import { getTileIconColorClass } from '@/blocks/icon-color' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' import { useVariablesStore } from '@/stores/variables/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' @@ -39,21 +46,34 @@ interface SubBlockValueEntry { */ const HANDLE_STYLES = { horizontal: '!border-none !bg-[var(--surface-7)] !h-5 !w-[7px] !rounded-xs', - vertical: '!border-none !bg-[var(--surface-7)] !h-[7px] !w-5 !rounded-xs', right: '!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none', error: - '!z-[10] !border-none !bg-[var(--text-error)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none', + '!z-[10] !border-none !bg-[var(--text-error)] !h-[7px] !w-6 !rounded-b-[2px] !rounded-t-none', } as const /** Reusable style object for error handles positioned at bottom-right */ const ERROR_HANDLE_STYLE: CSSProperties = { - right: '-7px', + right: 'auto', top: 'auto', - bottom: `${HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET}px`, - transform: 'translateY(50%)', + bottom: '-7px', + left: 'calc(100% - 30px)', + transform: 'translateX(-50%)', } +const getReactFlowPosition = (side: PositionedSourceHandleSide) => + 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 @@ -179,7 +199,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps workflowMap = {}, workflowLabelsReady = false, isTrigger = false, - horizontalHandles = false, enabled = true, isPreviewSelected = false, executionStatus, @@ -202,6 +221,11 @@ function WorkflowPreviewBlockInner({ data }: NodeProps }, {}) }, [subBlockValues, lightweight]) + const canvasPresentation = useMemo( + () => (blockConfig ? resolveCanvasBlockPresentation(blockConfig, name, rawValues) : undefined), + [blockConfig, name, rawValues] + ) + const visibleSubBlocks = useMemo(() => { if (!blockConfig?.subBlocks) return [] @@ -231,8 +255,16 @@ 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 + } + if ( + canvasPresentation?.usesDefaultTitle && + subBlock.id === canvasPresentation.operationSubBlockId + ) { + return false + } + return hasDisplayableRowValue(subBlock, rawValues[subBlock.id]) }) }, [ lightweight, @@ -243,6 +275,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps isTrigger, canonicalIndex, rawValues, + canvasPresentation, ]) /** @@ -320,7 +353,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps return defaultRows }, [type, rawValues, lightweight]) - if (!blockConfig) { + if (!blockConfig || !canvasPresentation) { return null } @@ -341,57 +374,52 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const hasSuccess = executionStatus === 'success' 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(canvasPresentation.title)}
+ {!isNoteBlock && ( + + )}
{/* Content area with subblocks */} @@ -432,7 +460,12 @@ function WorkflowPreviewBlockInner({ data }: NodeProps return ( ) }) )} - {/* Error row for non-trigger blocks */} - {shouldShowDefaultHandles && ( - - )}
)} @@ -470,13 +495,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps /> ) })} - )} @@ -498,13 +516,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps /> ) })} - )} @@ -513,26 +524,49 @@ function WorkflowPreviewBlockInner({ data }: NodeProps <> - {shouldShowDefaultHandles && ( + {POSITIONED_SOURCE_HANDLE_SIDES.map((side) => (
) } @@ -553,7 +587,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/preview/components/preview-workflow/components/subflow/subflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx index 922dc37dad9..3027f64de2a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/subflow/subflow.tsx @@ -1,11 +1,8 @@ 'use client' import { memo } from 'react' -import { Badge, cn } from '@sim/emcn' -import { HANDLE_POSITIONS } from '@sim/workflow-renderer' -import { RepeatIcon, SplitIcon } from 'lucide-react' -import { Handle, type NodeProps, Position } from 'reactflow' -import { getTileIconColorClass } from '@/blocks/icon-color' +import { SubflowNodeView } from '@sim/workflow-renderer' +import type { NodeProps } from 'reactflow' /** Execution status for subflows in preview mode */ type ExecutionStatus = 'success' | 'error' | 'not-executed' @@ -30,122 +27,19 @@ interface WorkflowPreviewSubflowData { * Renders loop/parallel containers without hooks, store subscriptions, * or interactive features. */ -function WorkflowPreviewSubflowInner({ data }: NodeProps) { - const { - name, - width = 500, - height = 300, - kind, - enabled = true, - isPreviewSelected = false, - executionStatus, - } = data - - const isLoop = kind === 'loop' - const BlockIcon = isLoop ? RepeatIcon : SplitIcon - const blockIconBg = isLoop ? '#2FB3FF' : '#FEE12B' - const blockName = name || (isLoop ? 'Loop' : 'Parallel') - - const startHandleId = isLoop ? 'loop-start-source' : 'parallel-start-source' - const endHandleId = isLoop ? 'loop-end-source' : 'parallel-end-source' - - const leftHandleClass = - '!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none' - const rightHandleClass = - '!z-[10] !border-none !bg-[var(--workflow-edge)] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none' - - const hasError = executionStatus === 'error' - const hasSuccess = executionStatus === 'success' - +function WorkflowPreviewSubflowInner({ data, id }: NodeProps) { 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 on left (input to the subflow) */} - - - {/* Header - matches actual subflow header structure */} -
-
-
- -
- - {blockName} - -
- {!enabled && disabled} -
- - {/* Content area - matches workflow structure */} -
- {/* Subflow Start - connects to first block in subflow */} -
- Start - -
-
- - {/* End source handle on right (output from the subflow) */} - -
+ undefined} + /> ) } 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..de225ea3b9d 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 @@ -57,13 +57,15 @@ export const ActionsGroup = memo(function ActionsGroup({ export const BlocksGroup = memo(function BlocksGroup({ items, onSelect, + heading = 'Blocks', }: { items: SearchBlockItem[] onSelect: (block: SearchBlockItem) => void + heading?: string | null }) { if (items.length === 0) return null return ( - + {items.map((block) => ( ))} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx index df2d2d97900..ec5b0aab037 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx @@ -612,7 +612,12 @@ export function SearchModal({ const filteredToolOps = useMemo(() => { if (!isOnWorkflowPage) return [] - return filterAndCap(toolOperations, (op) => op.searchValue, deferredSearch) + return filterAndCap( + toolOperations, + (op) => op.name, + deferredSearch, + (op) => op.searchValue + ) }, [isOnWorkflowPage, toolOperations, deferredSearch]) const filteredDocs = useMemo(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts index a82b79fd5f9..6f5d50a2cf8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/utils.test.ts @@ -275,6 +275,17 @@ describe('filterAndSort — name ranked above secondary text', () => { expect(filterAndSort(items, toName, 'gpt-4o', toExtra)).toHaveLength(1) }) + it('ranks a visible operation-name match above a service metadata match', () => { + const items: Item[] = [ + { name: 'List Calls', searchValue: 'AgentPhone List Calls' }, + { name: 'Agent Status', searchValue: 'Example Agent Status' }, + ] + + const sorted = filterAndSort(items, toName, 'agent', toExtra) + + expect(sorted.map((item) => item.name)).toEqual(['Agent Status', 'List Calls']) + }) + it('is byte-identical to single-field ranking when no secondary accessor is given', () => { const items = ['Slack message', 'Send message to Slack'] expect(filterAndSort(items, (s) => s, 'slack')).toEqual( 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/blocks/blocks/gmail.ts b/apps/sim/blocks/blocks/gmail.ts index af65d44ffd5..10ae9f7c399 100644 --- a/apps/sim/blocks/blocks/gmail.ts +++ b/apps/sim/blocks/blocks/gmail.ts @@ -56,6 +56,13 @@ export const GmailBlock: BlockConfig = { integrationType: IntegrationType.Email, bgColor: '#FFFFFF', icon: GmailIcon, + canvasPresentation: { + typeLabel: 'Gmail', + defaultTitle: 'Send Email', + defaultName: 'Gmail', + operationSubBlockId: 'operation', + operationRowTitle: 'Action', + }, hideFromToolbar: true, sunset: { status: 'legacy', replacedBy: 'gmail_v2' }, triggerAllowed: true, diff --git a/apps/sim/blocks/blocks/human_in_the_loop.ts b/apps/sim/blocks/blocks/human_in_the_loop.ts index 99310a4913f..0f63c3bc20a 100644 --- a/apps/sim/blocks/blocks/human_in_the_loop.ts +++ b/apps/sim/blocks/blocks/human_in_the_loop.ts @@ -4,7 +4,7 @@ import type { ResponseBlockOutput } from '@/tools/response/types' export const HumanInTheLoopBlock: BlockConfig = { type: 'human_in_the_loop', - name: 'Human in the Loop', + name: 'Human', description: 'Pause workflow execution and wait for human input', longDescription: 'Combines response and start functionality. Sends structured responses and allows workflow to resume from this point.', @@ -12,6 +12,11 @@ export const HumanInTheLoopBlock: BlockConfig = { bgColor: '#10B981', docsLink: 'https://docs.sim.ai/workflows/blocks/human-in-the-loop', icon: HumanInTheLoopIcon, + canvasPresentation: { + typeLabel: 'Human', + defaultTitle: 'Wait for Input', + defaultName: 'Human in the Loop', + }, subBlocks: [ // Operation dropdown hidden - block defaults to human approval mode // { diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 716625d7102..364978b87c3 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -507,6 +507,19 @@ export interface BlockConfig { */ iconColor?: string icon: BlockIcon + /** Canvas-only naming rules. Stored block names remain unchanged for references and serialization. */ + canvasPresentation?: { + /** Stable provider or block-kind label shown in the header tag. */ + typeLabel?: string + /** Semantic title used when the stored block name is still auto-generated. */ + defaultTitle: string + /** Additional generated-name prefix retained for legacy block instances. */ + defaultName?: string + /** Subblock whose selected option replaces the default title. */ + operationSubBlockId?: string + /** Label used for the operation row after a user gives the block a custom name. */ + operationRowTitle?: string + } subBlocks: SubBlockConfig[] triggerAllowed?: boolean authMode?: AuthMode diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index deb3548b350..66c68680b00 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 ( "; + inherits: true; + initial-value: 0; +} + +@property --tl-relay-right-cap-progress { + syntax: ""; + inherits: true; + initial-value: 0; +} + +@property --tl-relay-b1 { + syntax: ""; + inherits: true; + initial-value: 25%; +} + +@property --tl-relay-b2 { + syntax: ""; + inherits: true; + initial-value: 50%; +} + +@property --tl-relay-b3 { + syntax: ""; + inherits: true; + initial-value: 75%; +} + +.relayWideLayout { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + contain: layout paint; + animation: relay-wide-wave 1.6s linear infinite both; +} + +.relayCapFrame { + position: absolute; + top: 0; + width: 40px; + height: 24px; + overflow: visible; +} + +.relayLeftCapFrame { + left: 0; +} + +.relayRightCapFrame { + right: 0; +} + +.relayCapExtension { + position: absolute; + top: 0; + height: 24px; + background: currentColor; + will-change: width; +} + +.relayLeftCap { + left: 35px; + width: calc(5px + var(--tl-relay-left-cap-progress) * 1px); + border-radius: 0 4px 4px 0; +} + +.relayRightCap { + right: 35px; + width: calc(5px + var(--tl-relay-right-cap-progress) * 1px); + border-radius: 4px 0 0 4px; +} + +.relayActivityTrack { + position: absolute; + top: 0; + right: calc(42px + var(--tl-relay-right-cap-progress) * 1px); + bottom: 0; + left: calc(42px + var(--tl-relay-left-cap-progress) * 1px); + contain: layout paint; +} + +.relayActivityBlock { + position: absolute; + inset: 0; + background: currentColor; + clip-path: inset(0 calc(100% - var(--tl-relay-b1) + 1px) 0 0 round 4px); + will-change: clip-path; +} + +.relayActivityBlockSecond { + clip-path: inset( + 0 calc(100% - var(--tl-relay-b2) + 1px) 0 calc(var(--tl-relay-b1) + 1px) round 4px + ); +} + +.relayActivityBlockThird { + clip-path: inset( + 0 calc(100% - var(--tl-relay-b3) + 1px) 0 calc(var(--tl-relay-b2) + 1px) round 4px + ); +} + +.relayActivityBlockFourth { + clip-path: inset(0 0 0 calc(var(--tl-relay-b3) + 1px) round 4px); +} + +@keyframes relay-wide-wave { + 0%, + 100% { + --tl-relay-b1: 10.655%; + --tl-relay-b2: 24.978%; + --tl-relay-b3: 58.16%; + --tl-relay-left-cap-progress: 0; + --tl-relay-right-cap-progress: 0.976; + } + 5% { + --tl-relay-b1: 14.423%; + --tl-relay-b2: 27.648%; + --tl-relay-b3: 55.273%; + --tl-relay-left-cap-progress: 0.095; + --tl-relay-right-cap-progress: 0.994; + } + 10% { + --tl-relay-b1: 21.161%; + --tl-relay-b2: 33.204%; + --tl-relay-b3: 55.113%; + --tl-relay-left-cap-progress: 0.295; + --tl-relay-right-cap-progress: 1; + } + 15% { + --tl-relay-b1: 28.65%; + --tl-relay-b2: 40.122%; + --tl-relay-b3: 58.089%; + --tl-relay-left-cap-progress: 0.513; + --tl-relay-right-cap-progress: 0.905; + } + 20% { + --tl-relay-b1: 35.828%; + --tl-relay-b2: 47.584%; + --tl-relay-b3: 63.388%; + --tl-relay-left-cap-progress: 0.683; + --tl-relay-right-cap-progress: 0.705; + } + 25% { + --tl-relay-b1: 40.906%; + --tl-relay-b2: 56.326%; + --tl-relay-b3: 70.465%; + --tl-relay-left-cap-progress: 0.802; + --tl-relay-right-cap-progress: 0.487; + } + 30% { + --tl-relay-b1: 42.672%; + --tl-relay-b2: 64.684%; + --tl-relay-b3: 77.21%; + --tl-relay-left-cap-progress: 0.885; + --tl-relay-right-cap-progress: 0.317; + } + 35% { + --tl-relay-b1: 42.602%; + --tl-relay-b2: 70.912%; + --tl-relay-b3: 82.247%; + --tl-relay-left-cap-progress: 0.941; + --tl-relay-right-cap-progress: 0.198; + } + 40% { + --tl-relay-b1: 42.139%; + --tl-relay-b2: 74.843%; + --tl-relay-b3: 85.574%; + --tl-relay-left-cap-progress: 0.976; + --tl-relay-right-cap-progress: 0.115; + } + 45% { + --tl-relay-b1: 40.427%; + --tl-relay-b2: 75.01%; + --tl-relay-b3: 88.046%; + --tl-relay-left-cap-progress: 0.994; + --tl-relay-right-cap-progress: 0.059; + } + 50% { + --tl-relay-b1: 37.684%; + --tl-relay-b2: 72.125%; + --tl-relay-b3: 89.89%; + --tl-relay-left-cap-progress: 1; + --tl-relay-right-cap-progress: 0.024; + } + 55% { + --tl-relay-b1: 33.489%; + --tl-relay-b2: 67.94%; + --tl-relay-b3: 90.833%; + --tl-relay-left-cap-progress: 0.905; + --tl-relay-right-cap-progress: 0.006; + } + 60% { + --tl-relay-b1: 28.085%; + --tl-relay-b2: 63.498%; + --tl-relay-b3: 90.982%; + --tl-relay-left-cap-progress: 0.705; + --tl-relay-right-cap-progress: 0; + } + 65% { + --tl-relay-b1: 22.094%; + --tl-relay-b2: 57.865%; + --tl-relay-b3: 88.465%; + --tl-relay-left-cap-progress: 0.487; + --tl-relay-right-cap-progress: 0.095; + } + 70% { + --tl-relay-b1: 16.986%; + --tl-relay-b2: 51.788%; + --tl-relay-b3: 83.593%; + --tl-relay-left-cap-progress: 0.317; + --tl-relay-right-cap-progress: 0.295; + } + 75% { + --tl-relay-b1: 13.648%; + --tl-relay-b2: 45.485%; + --tl-relay-b3: 78.236%; + --tl-relay-left-cap-progress: 0.198; + --tl-relay-right-cap-progress: 0.513; + } + 80% { + --tl-relay-b1: 11.758%; + --tl-relay-b2: 38.997%; + --tl-relay-b3: 73.344%; + --tl-relay-left-cap-progress: 0.115; + --tl-relay-right-cap-progress: 0.683; + } + 85% { + --tl-relay-b1: 10.679%; + --tl-relay-b2: 32.987%; + --tl-relay-b3: 69.104%; + --tl-relay-left-cap-progress: 0.059; + --tl-relay-right-cap-progress: 0.802; + } + 90% { + --tl-relay-b1: 10.047%; + --tl-relay-b2: 28.326%; + --tl-relay-b3: 65.775%; + --tl-relay-left-cap-progress: 0.024; + --tl-relay-right-cap-progress: 0.885; + } + 95% { + --tl-relay-b1: 10.022%; + --tl-relay-b2: 25.718%; + --tl-relay-b3: 62.334%; + --tl-relay-left-cap-progress: 0.006; + --tl-relay-right-cap-progress: 0.941; + } +} + /* corners — four dots rotate around a center square */ .cornersA { animation: corners-a 0.8s infinite; @@ -354,6 +612,9 @@ .labelIn { animation: none; } + .stage { + transition: none; + } .gradInner, .gradOuter, .glow { diff --git a/apps/sim/components/ui/thinking-loader.test.tsx b/apps/sim/components/ui/thinking-loader.test.tsx new file mode 100644 index 00000000000..ef3d5f923c5 --- /dev/null +++ b/apps/sim/components/ui/thinking-loader.test.tsx @@ -0,0 +1,143 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { ThinkingLoader } from '@/components/ui' + +beforeAll(() => { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + onchange: null, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia +}) + +afterEach(() => { + vi.useRealTimers() + document.body.replaceChildren() +}) + +describe('ThinkingLoader', () => { + it('keeps the play silhouette mounted while entering the morph cycle', () => { + vi.useFakeTimers() + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + + act(() => { + root.render() + }) + + const playPath = host.querySelector("path[d^='M 31 20']") + expect(playPath).not.toBeNull() + expect(playPath?.closest('g')?.className.baseVal).toContain('stageActive') + + act(() => { + root.render( + + ) + }) + + expect(host.querySelector("path[d^='M 31 20']")).toBe(playPath) + expect(host.querySelectorAll('svg')).toHaveLength(1) + expect(playPath?.closest('g')?.className.baseVal).toContain('stageActive') + expect(host.querySelector('svg')?.style.getPropertyValue('--tl-morph-duration')).toBe('650ms') + + act(() => { + vi.advanceTimersByTime(140) + }) + + expect(playPath?.closest('g')?.className.baseVal).not.toContain('stageActive') + + act(() => { + root.render( + + ) + }) + + expect(host.querySelectorAll("g[class*='stage']")).toHaveLength(9) + expect(playPath?.closest('g')?.className.baseVal).toContain('stageActive') + + act(() => { + vi.advanceTimersByTime(180) + }) + + expect(host.querySelectorAll("g[class*='stage']")).toHaveLength(1) + + act(() => { + root.unmount() + }) + }) + + it('expands the relay geometry without changing the default layout', () => { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + + act(() => { + root.render( + + ) + }) + + const wideSvg = host.querySelector('svg') + expect(wideSvg?.getAttribute('viewBox')).toBeNull() + expect(wideSvg?.getAttribute('width')).toBe('160') + expect(wideSvg?.getAttribute('height')).toBe('24') + expect(wideSvg?.getAttribute('preserveAspectRatio')).toBeNull() + expect(wideSvg?.getAttribute('class')).toContain('w-full') + expect(wideSvg?.style.getPropertyValue('--tl-glow')).toBe('transparent') + expect(wideSvg?.style.getPropertyValue('--tl-sync')).toBe('0ms') + expect(host.querySelector("path[d^='M23.75 0A8']")).not.toBeNull() + expect(host.querySelector("path[d^='M16.25 0A8']")).not.toBeNull() + expect(host.querySelector("span[class*='relayLeftCap']")).not.toBeNull() + expect(host.querySelector("span[class*='relayRightCap']")).not.toBeNull() + const wideLayout = host.querySelector("foreignObject[width='100%'][height='24'] > div") + expect(wideLayout?.getAttribute('class')).toContain('relayWideLayout') + const activityTrack = wideLayout?.querySelector("div[class*='relayActivityTrack']") + expect(activityTrack?.getAttribute('class')).toContain('relayActivityTrack') + const activityBlocks = activityTrack?.querySelectorAll('span') ?? [] + expect(activityBlocks).toHaveLength(4) + expect(activityBlocks[0]?.getAttribute('class')).toContain('relayActivityBlock') + expect(activityBlocks[1]?.getAttribute('class')).toContain('relayActivityBlockSecond') + expect(activityBlocks[2]?.getAttribute('class')).toContain('relayActivityBlockThird') + expect(activityBlocks[3]?.getAttribute('class')).toContain('relayActivityBlockFourth') + const capFrames = host.querySelectorAll("svg[viewBox='0 0 40 24']") + expect(capFrames).toHaveLength(2) + expect(capFrames[0]?.getAttribute('class')).toContain('relayLeftCapFrame') + expect(capFrames[1]?.getAttribute('class')).toContain('relayRightCapFrame') + + act(() => { + root.render() + }) + + const compactSvg = host.querySelector('svg') + expect(compactSvg?.getAttribute('viewBox')).toBe('0 0 100 100') + expect(compactSvg?.getAttribute('width')).toBe('24') + expect(compactSvg?.getAttribute('preserveAspectRatio')).toBeNull() + expect(compactSvg?.style.getPropertyValue('--tl-relay-distance')).toBe('') + + act(() => { + root.unmount() + }) + }) +}) diff --git a/apps/sim/components/ui/thinking-loader.tsx b/apps/sim/components/ui/thinking-loader.tsx index 700d39c9a56..d17b27c6dee 100644 --- a/apps/sim/components/ui/thinking-loader.tsx +++ b/apps/sim/components/ui/thinking-loader.tsx @@ -5,6 +5,7 @@ import { cn } from '@sim/emcn' import styles from '@/components/ui/thinking-loader.module.css' const VARIANTS = [ + 'play', 'metaballs', 'relay', 'corners', @@ -18,11 +19,10 @@ const VARIANTS = [ export type ThinkingLoaderVariant = (typeof VARIANTS)[number] /** - * Shapes used in the random morph cycle. `orb` (the solid terminal circle) is - * excluded — it is only reachable by pinning `variant='orb'` or via `settle`, - * so the cycle never lands on it mid-stream. + * Shapes used in the random morph cycle. The `play` entry shape and `orb` + * terminal shape are excluded so the cycle never lands on either mid-stream. */ -const CYCLE_VARIANTS = VARIANTS.filter((v) => v !== 'orb') +const CYCLE_VARIANTS = VARIANTS.filter((v) => v !== 'play' && v !== 'orb') /** * Deterministic integer hash — turns a step index into a spread-out pseudo- @@ -44,6 +44,7 @@ function hashStep(n: number): number { * animation-delay that phase-locks instances mounted at different times. */ const SYNC_PERIOD_MS = 12_000 +const DEFAULT_MORPH_DURATION_MS = 500 /** * A super-cycle of steps, each holding a pseudo-random shape for a pseudo-random @@ -89,6 +90,9 @@ function variantAtNow(): { variant: ThinkingLoaderVariant; msUntilNext: number } * contain-fit to the canvas. Animations live in the CSS module. */ const VARIANT_SHAPES: Record = { + play: ( + + ), metaballs: ( <> @@ -160,12 +164,52 @@ const VARIANT_SHAPES: Record = { orb: , } +const WIDE_RELAY_WIDTH = 160 +const WIDE_RELAY_HEIGHT = 24 +const WIDE_RELAY_LEFT_CAP_PATH = + 'M23.75 0A8 8 0 0 0 17.6 2.88L3.41 19.9A2.5 2.5 0 0 0 5.34 24L36 24L36 0Z' +const WIDE_RELAY_RIGHT_CAP_PATH = + 'M16.25 0A8 8 0 0 1 22.4 2.88L36.59 19.9A2.5 2.5 0 0 1 34.66 24L4 24L4 0Z' +const WIDE_RELAY_LAYOUT = ( + +
+ +
+
+) + /** * World-aligned status phrase per shape (used when `phase` is set). Each phrase * names what the shape represents in the Sim world, so the words on screen always * match the loader. Keys are the shape names; the comment is the world concept. */ const VARIANT_PHRASE: Record = { + play: 'Ready', corners: 'Orchestrating…', // Mothership — the Core directing the work squeeze: 'Working…', // Pod — one agent on a task compass: 'In formation…', // Formation — many Pods in parallel @@ -179,12 +223,16 @@ const VARIANT_PHRASE: Record = { export interface ThinkingLoaderProps { /** Pin one pattern. When omitted, the loader morphs between all patterns at random. */ variant?: ThinkingLoaderVariant + /** Expand a pinned relay across a horizontal container while preserving its canonical motion. */ + relayLayout?: 'compact' | 'wide' /** * When cycling, open on this pattern (held one beat) before joining the * shared morph timeline. Use `'corners'` (the Mothership shape) to always * begin on the Core. Ignored when `variant` pins a single pattern. */ startVariant?: ThinkingLoaderVariant + /** Time to keep the entry shape painted before joining the shared cycle. */ + startHoldMs?: number /** * Stop cycling and morph to the solid `orb` disc — the loader's terminal * resting shape. The goo filter melts the current shape into the orb (no hard @@ -194,6 +242,8 @@ export interface ThinkingLoaderProps { settle?: boolean /** Rendered square size in px. Defaults to 20. */ size?: number + /** Shape-to-shape goo overlap duration. Defaults to the loader's CSS timing. */ + morphDurationMs?: number /** Optional status text (e.g. "Thinking…") rendered beside the goo with a shimmer sweep. */ label?: string /** @@ -214,6 +264,8 @@ export interface ThinkingLoaderProps { * sweep — while the phrase crossfade still applies. */ shimmer?: boolean + /** Inherit the surrounding control's current text color instead of using the default loader ink. */ + tone?: 'default' | 'inherit' /** Layout-only classes (margins, alignment). The loader owns its chrome. */ className?: string /** @@ -241,16 +293,21 @@ export interface ThinkingLoaderProps { */ export function ThinkingLoader({ variant, + relayLayout = 'compact', startVariant, + startHoldMs = STEP_MIN_MS, settle = false, size = 20, + morphDurationMs, label, phase, labelRatio = 0.7, shimmer = true, + tone = 'default', className, style, }: ThinkingLoaderProps) { + const isWideRelay = variant === 'relay' && relayLayout === 'wide' // useId emits colons, which break url(#...) filter references — strip them. const id = useId().replace(/[^a-zA-Z0-9-]/g, '') const filterId = `tl-goo-${id}` @@ -258,9 +315,16 @@ export function ThinkingLoader({ const windowClipId = `tl-window-${id}` const gradientId = `tl-grad-${id}` const [cycleVariant, setCycleVariant] = useState( - startVariant ?? 'metaballs' + variant ?? startVariant ?? 'metaballs' ) const cycling = variant === undefined + const [retainMorphStages, setRetainMorphStages] = useState(cycling) + + useEffect(() => { + if (variant !== undefined) { + setCycleVariant(variant) + } + }, [variant]) useEffect(() => { if (!cycling) return @@ -269,7 +333,10 @@ export function ThinkingLoader({ setCycleVariant('orb') return } - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + setCycleVariant('thinking') + return + } // When a startVariant is given, hold it for one min-step so the cycle always // opens on that shape, then join the shared wall-clock morph timeline. @@ -279,7 +346,7 @@ export function ThinkingLoader({ if (!opened) { opened = true setCycleVariant(startVariant as ThinkingLoaderVariant) - timeout = setTimeout(tick, STEP_MIN_MS) + timeout = setTimeout(tick, startHoldMs) return } const { variant: next, msUntilNext } = variantAtNow() @@ -288,17 +355,36 @@ export function ThinkingLoader({ } tick() return () => clearTimeout(timeout) - }, [cycling, startVariant, settle]) + }, [cycling, startHoldMs, startVariant, settle]) + + useEffect(() => { + if (cycling) { + setRetainMorphStages(true) + return + } + if (!retainMorphStages) return + + const timeout = window.setTimeout( + () => setRetainMorphStages(false), + morphDurationMs ?? DEFAULT_MORPH_DURATION_MS + ) + return () => window.clearTimeout(timeout) + }, [cycling, morphDurationMs, retainMorphStages]) // Phase-lock the CSS animations to the wall clock (set after mount so // server and client markup agree). All instances share the same negative // delay modulus, so their keyframes line up regardless of mount time. - const [syncDelay, setSyncDelay] = useState(undefined) + const [syncDelay, setSyncDelay] = useState(isWideRelay ? '0ms' : undefined) useEffect(() => { + if (isWideRelay) { + setSyncDelay('0ms') + return + } setSyncDelay(`-${Date.now() % SYNC_PERIOD_MS}ms`) - }, []) + }, [isWideRelay]) const shown = variant ?? cycleVariant + const renderedWidth = isWideRelay ? WIDE_RELAY_WIDTH : size // `phase` shows the world phrase for the shape on screen; otherwise the // caller's static label (if any). Cycling, it updates as the shape morphs. const displayLabel = phase ? VARIANT_PHRASE[shown] : label @@ -319,19 +405,31 @@ export function ThinkingLoader({ return () => clearTimeout(timeout) }, [exitingLabel]) - const stages = cycling ? VARIANTS : [shown] + const stages = cycling || retainMorphStages ? VARIANTS : [shown] const loader = ( @@ -401,22 +499,26 @@ export function ThinkingLoader({ - - {stages.map((v) => ( - - {VARIANT_SHAPES[v]} - - ))} - + {isWideRelay ? ( + {WIDE_RELAY_LAYOUT} + ) : ( + + {stages.map((v) => ( + + {VARIANT_SHAPES[v]} + + ))} + + )} ) diff --git a/apps/sim/executor/execution/edge-manager.test.ts b/apps/sim/executor/execution/edge-manager.test.ts index 80bd4100d34..4d28b2d947e 100644 --- a/apps/sim/executor/execution/edge-manager.test.ts +++ b/apps/sim/executor/execution/edge-manager.test.ts @@ -531,6 +531,29 @@ describe('EdgeManager', () => { 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 ca73695d24f..27bb0d43115 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) @@ -232,6 +233,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() @@ -1301,6 +1305,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) { @@ -1549,13 +1565,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( @@ -2255,6 +2271,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 60db7b61c7c..5cce894e6b4 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -37,6 +37,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({ @@ -62,6 +63,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 36bc722a0f8..11d84318875 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 { isHosted as isHostedDeployment } from '@/lib/core/config/env-flags' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' @@ -669,7 +670,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 { @@ -692,7 +697,7 @@ export function validateSourceHandleForBlock( } default: - if (sourceHandle === 'source') { + if (sourceHandle === 'source' || isPositionedSourceHandle(sourceHandle)) { return { valid: true } } return { diff --git a/apps/sim/lib/ui/glass-surface.ts b/apps/sim/lib/ui/glass-surface.ts new file mode 100644 index 00000000000..df6ab68fae4 --- /dev/null +++ b/apps/sim/lib/ui/glass-surface.ts @@ -0,0 +1,3 @@ +/** Shared translucent surface used when content should remain visible through frosted chrome. */ +export const FROSTED_GLASS_SURFACE = + 'bg-[color-mix(in_srgb,var(--bg)_92%,transparent)] backdrop-blur-2xl' diff --git a/apps/sim/lib/workflows/autolayout/core.test.ts b/apps/sim/lib/workflows/autolayout/core.test.ts index 233147cadd5..9a745a04bef 100644 --- a/apps/sim/lib/workflows/autolayout/core.test.ts +++ b/apps/sim/lib/workflows/autolayout/core.test.ts @@ -1,7 +1,6 @@ /** * @vitest-environment node */ -import { HANDLE_POSITIONS } from '@sim/workflow-renderer' import { describe, expect, it, vi } from 'vitest' import { layoutBlocksCore } from '@/lib/workflows/autolayout/core' import type { Edge } from '@/lib/workflows/autolayout/types' @@ -98,11 +97,10 @@ describe('layoutBlocksCore', () => { const { nodes } = layoutBlocksCore(blocks, edges, { isContainer: false }) const sourceNode = nodes.get('source')! - // The error branch's target handle lines up with the source's error handle, - // which sits ERROR_BOTTOM_OFFSET above the source's real bottom edge. - const errorHandleY = - sourceNode.position.y + sourceNode.metrics.height - HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET - expect(nodes.get('failed')!.position.y + HANDLE_POSITIONS.DEFAULT_Y_OFFSET).toBe(errorHandleY) + // The error branch's centered target lines up with the source's bottom-edge error handle. + const failedNode = nodes.get('failed')! + const errorHandleY = sourceNode.position.y + sourceNode.metrics.height + expect(failedNode.position.y + failedNode.metrics.height / 2).toBe(errorHandleY) expect(nodes.get('ok')!.position.y).toBeLessThan(nodes.get('failed')!.position.y) }) }) diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts index a1d5ec5e6b3..e4214cb3b1c 100644 --- a/apps/sim/lib/workflows/autolayout/core.ts +++ b/apps/sim/lib/workflows/autolayout/core.ts @@ -32,7 +32,7 @@ function getSourceHandleYOffset(node: GraphNode, sourceHandle?: string | null): const block = node.block if (sourceHandle === 'error') { - return node.metrics.height - HANDLE_POSITIONS.ERROR_BOTTOM_OFFSET + return node.metrics.height } if (sourceHandle && SUBFLOW_START_HANDLES.has(sourceHandle)) { @@ -58,14 +58,25 @@ function getSourceHandleYOffset(node: GraphNode, sourceHandle?: string | null): } } - return HANDLE_POSITIONS.DEFAULT_Y_OFFSET + return getDefaultHandleYOffset(node) +} + +/** + * 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(node: GraphNode): number { + if (node.block.type === 'loop' || node.block.type === 'parallel') { + return HANDLE_POSITIONS.DEFAULT_Y_OFFSET + } + return node.metrics.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(node: GraphNode, _targetHandle?: string | null): number { + return getDefaultHandleYOffset(node) } /** @@ -340,7 +351,7 @@ export function calculatePositions( bestSourceHandleY = padding.y + HANDLE_POSITIONS.DEFAULT_Y_OFFSET } - const targetHandleOffset = getTargetHandleYOffset(node.block, bestEdge?.targetHandle) + const targetHandleOffset = getTargetHandleYOffset(node, bestEdge?.targetHandle) node.position = { x: xPosition, y: bestSourceHandleY - targetHandleOffset } } diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts index ff18c4129b7..c8853f32b1b 100644 --- a/apps/sim/lib/workflows/autolayout/utils.ts +++ b/apps/sim/lib/workflows/autolayout/utils.ts @@ -1,4 +1,4 @@ -import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer' +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS, getNoteBlockHeight } from '@sim/workflow-renderer' import { AUTO_LAYOUT_EXCLUDED_TYPES, CONTAINER_BLOCK_TYPES, @@ -332,10 +332,7 @@ function getNoteDimensions(block: BlockState): { width: number; height: number } BLOCK_DIMENSIONS.FIXED_WIDTH ) - const defaultHeight = - BLOCK_DIMENSIONS.HEADER_HEIGHT + - BLOCK_DIMENSIONS.NOTE_CONTENT_PADDING + - BLOCK_DIMENSIONS.NOTE_BASE_CONTENT_HEIGHT + const defaultHeight = getNoteBlockHeight(false) const height = Math.max( resolveNumeric(block.data?.height, 0), diff --git a/apps/sim/lib/workflows/blocks/canvas-presentation.test.ts b/apps/sim/lib/workflows/blocks/canvas-presentation.test.ts new file mode 100644 index 00000000000..b1552980156 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/canvas-presentation.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { resolveCanvasBlockPresentation } from '@/lib/workflows/blocks/canvas-presentation' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' + +const operationSubBlock = { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Send Email', id: 'send_gmail' }, + { label: 'Search Email', id: 'search_gmail' }, + ], +} as SubBlockConfig + +const gmailConfig = { + name: 'Gmail', + subBlocks: [operationSubBlock], + canvasPresentation: { + typeLabel: 'Gmail', + defaultTitle: 'Send Email', + operationSubBlockId: 'operation', + operationRowTitle: 'Action', + }, +} as Pick + +describe('resolveCanvasBlockPresentation', () => { + it('uses the selected operation for an auto-generated block name', () => { + expect( + resolveCanvasBlockPresentation(gmailConfig, 'Gmail 1', { operation: 'search_gmail' }) + ).toEqual({ + title: 'Search Email', + typeLabel: 'Gmail', + usesDefaultTitle: true, + operationSubBlockId: 'operation', + operationRowTitle: 'Action', + }) + }) + + it('keeps a custom title and exposes operation row metadata', () => { + expect( + resolveCanvasBlockPresentation(gmailConfig, 'Customer Welcome', { + operation: 'send_gmail', + }) + ).toEqual({ + title: 'Customer Welcome', + typeLabel: 'Gmail', + usesDefaultTitle: false, + operationSubBlockId: 'operation', + operationRowTitle: 'Action', + }) + }) + + it('uses a static semantic title for a block without an operation selector', () => { + const humanConfig = { + name: 'Human', + subBlocks: [], + canvasPresentation: { + typeLabel: 'Human', + defaultTitle: 'Wait for Input', + defaultName: 'Human in the Loop', + }, + } as Pick + + expect(resolveCanvasBlockPresentation(humanConfig, 'Human in the Loop 1', {})).toEqual({ + title: 'Wait for Input', + typeLabel: 'Human', + usesDefaultTitle: true, + operationSubBlockId: undefined, + operationRowTitle: undefined, + }) + + expect(resolveCanvasBlockPresentation(humanConfig, 'Human 1', {})).toMatchObject({ + title: 'Wait for Input', + typeLabel: 'Human', + usesDefaultTitle: true, + }) + }) +}) diff --git a/apps/sim/lib/workflows/blocks/canvas-presentation.ts b/apps/sim/lib/workflows/blocks/canvas-presentation.ts new file mode 100644 index 00000000000..beff55cffc2 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/canvas-presentation.ts @@ -0,0 +1,67 @@ +import { resolveDropdownLabel } from '@/lib/workflows/subblocks/display' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' + +type CanvasPresentationConfig = Pick + +export interface CanvasBlockPresentation { + title: string + typeLabel: string + usesDefaultTitle: boolean + operationSubBlockId?: string + operationRowTitle?: string +} + +function isGeneratedBlockName(name: string, config: CanvasPresentationConfig): boolean { + const candidates = [config.name, config.canvasPresentation?.defaultName].filter( + (candidate): candidate is string => Boolean(candidate) + ) + const normalizedName = name.trim().toLocaleLowerCase() + + return candidates.some((candidate) => { + const normalizedCandidate = candidate.trim().toLocaleLowerCase() + if (normalizedName === normalizedCandidate) return true + if (!normalizedName.startsWith(`${normalizedCandidate} `)) return false + + const suffix = normalizedName.slice(normalizedCandidate.length + 1) + return /^\d+$/.test(suffix) + }) +} + +function getOperationTitle( + subBlocks: SubBlockConfig[], + operationSubBlockId: string | undefined, + rawValues: Record +): string | null { + if (!operationSubBlockId) return null + const operationSubBlock = subBlocks.find((subBlock) => subBlock.id === operationSubBlockId) + return resolveDropdownLabel(operationSubBlock, rawValues[operationSubBlockId]) +} + +/** Resolves semantic canvas copy without changing the block's persisted internal name. */ +export function resolveCanvasBlockPresentation( + config: CanvasPresentationConfig, + storedName: string, + rawValues: Record +): CanvasBlockPresentation { + const presentation = config.canvasPresentation + if (!presentation) { + return { + title: storedName, + typeLabel: config.name, + usesDefaultTitle: false, + } + } + + const usesDefaultTitle = isGeneratedBlockName(storedName, config) + const operationTitle = usesDefaultTitle + ? getOperationTitle(config.subBlocks, presentation.operationSubBlockId, rawValues) + : null + + return { + title: usesDefaultTitle ? (operationTitle ?? presentation.defaultTitle) : storedName, + typeLabel: presentation.typeLabel ?? config.name, + usesDefaultTitle, + operationSubBlockId: presentation.operationSubBlockId, + operationRowTitle: presentation.operationRowTitle, + } +} 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..bd1e42bd97b --- /dev/null +++ b/apps/sim/lib/workflows/blocks/workflow-block-border-mount.test.tsx @@ -0,0 +1,513 @@ +/** + * @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, + getNoteBlockHeight, + getWorkflowBorderFrameDeltaSeconds, + HANDLE_POSITIONS, + isActionMenuSwellReady, + NoteBlockView, + normalizeCursorSourceHandleId, + SubflowNodeView, + SubflowStartView, + 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 { ReactFlowProvider } from 'reactflow' +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() + vi.useRealTimers() +}) + +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') + /* Outputs always leave from the right, whichever edge the drag began on — + anchoring one on the left would put an outgoing line on the input port. */ + expect(normalizeCursorSourceHandleId('source-cursor-left')).toBe('source-right') + expect(normalizeCursorSourceHandleId('source-cursor-right')).toBe('source-right') + expect(normalizeCursorSourceHandleId('source-cursor-top')).toBe('source-right') + expect(normalizeCursorSourceHandleId('source-cursor-bottom')).toBe('source-right') + expect(normalizeCursorSourceHandleId('source-cursor-left', 'loop')).toBe('loop-end-source') + expect(normalizeCursorSourceHandleId('source-cursor-right', 'parallel')).toBe( + 'parallel-end-source' + ) + /* Anything that already reached the graph collapses the same way. */ + expect(normalizePositionedSourceHandleId('source-left')).toBe('source-right') + expect(normalizePositionedSourceHandleId('source-right')).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 once the swell can contain it', () => { + /* The row clips to the swell, so revealing it while the swell is shorter + than the buttons collides the icons with the card's top edge. Pin the + ratio, not the threshold constant — 24px of buttons in a 28px swell. */ + const ACTION_ROW_HEIGHT_PX = 24 + const OPEN_SWELL_HEIGHT_PX = 28 + const minimumSafeFraction = ACTION_ROW_HEIGHT_PX / OPEN_SWELL_HEIGHT_PX + + expect(isActionMenuSwellReady(1, minimumSafeFraction - 0.001)).toBe(false) + expect(isActionMenuSwellReady(1, 1)).toBe(true) + /* Never reveal while retracting, however far open the swell still is. */ + 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('paints a tall selector card across floating-point segment seams', () => { + const selectorPorts: WorkflowBorderPort[] = [ + { + id: 'target', + side: 'left', + position: 'center', + plateau: 38, + color: 'var(--text-secondary)', + magnetizable: false, + }, + ] + const { host } = mount( +
+ +
+ ) + const path = host.querySelector('svg path') + expect(path?.getAttribute('d')?.length ?? 0).toBeGreaterThan(0) + }) + + it('paints camera-followed execution with the selected silhouette color', () => { + const { host } = mount( +
+ +
+ ) + + expect(host.querySelector('path[fill="var(--text-secondary)"]')).toBeTruthy() + expect(host.querySelector('path[stroke="var(--border-success)"]')).toBeNull() + }) + + it('mounts a header-only card without throwing', () => { + const { host } = mount( +
+ +
+ ) + expect(host.querySelector('svg')).toBeTruthy() + }) + + it('bounds note content in a faded scroll viewport without connection handles', () => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + + const { host } = mount( + undefined} + actionBar={
Actions
} + /> + ) + + const scrollRegion = host.querySelector('[data-note-scroll-region]') + expect(scrollRegion).toHaveClass('allow-scroll', 'h-44', 'overflow-y-auto') + expect(scrollRegion?.getAttribute('class')).toContain('mask-image:linear-gradient') + expect(getNoteBlockHeight(false)).toBe(216) + expect(host.querySelector('[data-workflow-action-bar-bridge]')).toBeTruthy() + expect(host.querySelector('svg rect[fill="var(--surface-3)"]')).toBeTruthy() + expect(host.querySelector('[data-handleid]')).toBeNull() + }) + + it('paints the subflow Start source as a unified connection swell', () => { + const { host } = mount( + +
+ +
+
+ ) + + const start = host.querySelector('[data-node-role="loop-start"]') + expect(start).toHaveAttribute('data-connection-swell') + expect(start).toHaveClass('top-3') + expect(start?.querySelector('svg')).toHaveAttribute('viewBox', '-36 -36 130 106') + expect(start?.querySelector('[data-handleid="loop-start-source"]')).toBeTruthy() + expect(start?.querySelector('path[stroke="var(--text-secondary)"]')).toBeTruthy() + }) + + it('uses the standard card header and lower full-size ports for subflows', () => { + const { host } = mount( + + undefined} + /> + + ) + + const header = host.querySelector('[data-subflow-header]') + expect(header).toHaveClass('h-[40px]') + expect(header).not.toHaveClass('border-b') + expect(header).not.toHaveClass('bg-[var(--surface-2)]') + expect(host.querySelector('[data-subflow-type-tag="loop"]')).toHaveTextContent('Loop') + expect(host.querySelector('[data-handleid="target"]')).toHaveStyle({ top: '69px' }) + expect(host.querySelector('[data-handleid="loop-end-source"]')).toHaveStyle({ top: '69px' }) + expect(host.querySelector('svg')).toHaveAttribute('viewBox', '-36 -36 572 372') + expect(host.querySelector('svg rect[fill="var(--surface-3)"]')).toBeTruthy() + const dropTargetOutline = host.querySelector('[data-subflow-drop-target-outline]') + expect(dropTargetOutline).toHaveClass( + 'rounded-2xl', + 'ring-[1.5px]', + 'ring-[var(--text-secondary)]', + '[.subflow-node-drop-target_&]:opacity-100' + ) + }) + + it('retracts a selected loop action swell after hover ends', () => { + vi.useFakeTimers() + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + + const { host } = mount( + + undefined} + actionBar={
Actions
} + /> +
+ ) + + const actionMenuRoot = host.querySelector('[data-node-selected]') + expect(actionMenuRoot?.hasAttribute('data-action-menu-open')).toBe(false) + + act(() => actionMenuRoot?.dispatchEvent(new Event('pointerenter'))) + expect(actionMenuRoot?.hasAttribute('data-action-menu-open')).toBe(true) + + act(() => actionMenuRoot?.dispatchEvent(new Event('pointerleave'))) + act(() => vi.advanceTimersByTime(101)) + act(() => vi.advanceTimersByTime(41)) + expect(actionMenuRoot?.hasAttribute('data-action-menu-open')).toBe(false) + }) + + it('gives a nested block exclusive hover ownership over the loop action swell', () => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + + const { host } = mount( + +
+
+ undefined} + actionBar={
Loop actions
} + /> +
+
+
+ + ) + + const loopNode = host.querySelector('[data-loop-node="loop-1"]') + const actionMenuRoot = host.querySelector('[data-node-id="loop-1"]')?.parentElement + const nestedNode = host.querySelector('[data-nested-node="block-1"]') + const loopHeader = host.querySelector('[data-subflow-header]') + + act(() => loopNode?.dispatchEvent(new Event('pointerenter'))) + expect(actionMenuRoot).toHaveAttribute('data-action-menu-open') + + act(() => + loopNode?.dispatchEvent( + new MouseEvent('pointerleave', { relatedTarget: nestedNode, bubbles: false }) + ) + ) + expect(actionMenuRoot).not.toHaveAttribute('data-action-menu-open') + + act(() => loopNode?.dispatchEvent(new Event('pointerenter'))) + act(() => loopHeader?.dispatchEvent(new Event('pointerover', { bubbles: true }))) + expect(actionMenuRoot).toHaveAttribute('data-action-menu-open') + }) + + 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/blocks/workflow-block-view-interaction.test.tsx b/apps/sim/lib/workflows/blocks/workflow-block-view-interaction.test.tsx new file mode 100644 index 00000000000..3995dd9c6f3 --- /dev/null +++ b/apps/sim/lib/workflows/blocks/workflow-block-view-interaction.test.tsx @@ -0,0 +1,191 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { getWorkflowTypeAccent, WorkflowBlockView, WorkflowTypeTag } from '@sim/workflow-renderer' +import { createRoot, type Root } from 'react-dom/client' +import { ReactFlowProvider } from 'reactflow' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mountedRoots = new Set() +const mountedHosts = new Set() + +function TestIcon({ className }: { className?: string }) { + return +} + +function createView(isRunning: boolean, isEnabled = true, isLocked = false) { + return ( + + false} + onSelect={() => {}} + actionBar={
} + rows={null} + /> + + ) +} + +function flushAnimationFrames() { + act(() => { + vi.runAllTimers() + }) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal( + 'ResizeObserver', + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } + ) + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0) + ) + vi.stubGlobal('cancelAnimationFrame', (frameId: number) => window.clearTimeout(frameId)) + window.matchMedia = ((query: string) => ({ + matches: true, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + onchange: null, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia +}) + +afterEach(() => { + act(() => { + mountedRoots.forEach((root) => root.unmount()) + }) + mountedRoots.clear() + mountedHosts.forEach((host) => host.remove()) + mountedHosts.clear() + vi.runOnlyPendingTimers() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('WorkflowBlockView action menu', () => { + it('clears stale hover when execution stops and waits for a fresh pointer entry', () => { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + mountedRoots.add(root) + mountedHosts.add(host) + + act(() => root.render(createView(false))) + const actionMenuRoot = host.querySelector('.group.relative') + expect(actionMenuRoot).toBeTruthy() + + act(() => actionMenuRoot?.dispatchEvent(new Event('pointerenter'))) + flushAnimationFrames() + expect(actionMenuRoot).toHaveAttribute('data-action-menu-ready') + + act(() => root.render(createView(true))) + flushAnimationFrames() + expect(actionMenuRoot).toHaveAttribute('data-action-menu-ready') + + act(() => root.render(createView(false))) + flushAnimationFrames() + expect(actionMenuRoot).not.toHaveAttribute('data-action-menu-ready') + + act(() => actionMenuRoot?.dispatchEvent(new Event('pointerenter'))) + flushAnimationFrames() + expect(actionMenuRoot).toHaveAttribute('data-action-menu-ready') + }) + + it('places active block-state indicators before the type tag', () => { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + mountedRoots.add(root) + mountedHosts.add(host) + + act(() => root.render(createView(false, false, true))) + + const disabledIndicator = host.querySelector('[aria-label="Disabled"]') + const lockedIndicator = host.querySelector('[aria-label="Locked"]') + const typeTag = host.querySelector('[data-workflow-type-accent="function"]') + expect(Array.from(typeTag?.parentElement?.children ?? [])).toEqual([ + disabledIndicator, + lockedIndicator, + typeTag, + ]) + }) +}) + +describe('WorkflowTypeTag integration colors', () => { + it('keeps the selected Sim-native accents', () => { + expect(getWorkflowTypeAccent('agent')).toEqual({ variant: 'workflow', tone: 'inverse' }) + expect(getWorkflowTypeAccent('api')).toEqual({ variant: 'workflow', tone: 'blue' }) + expect(getWorkflowTypeAccent('loop')).toEqual({ variant: 'solid', tone: 'neutral' }) + expect(getWorkflowTypeAccent('parallel')).toEqual({ variant: 'workflow', tone: 'yellow' }) + expect(getWorkflowTypeAccent('router')).toEqual({ variant: 'workflow', tone: 'orange' }) + expect(getWorkflowTypeAccent('router_v2')).toEqual({ variant: 'workflow', tone: 'orange' }) + }) + + it('uses the provider background with a contrasting shared icon and label color', () => { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + mountedRoots.add(root) + mountedHosts.add(host) + + act(() => + root.render( + + ) + ) + + const tag = host.querySelector('[data-workflow-brand-tag]') + expect(tag).toHaveStyle({ background: '#6366F1' }) + expect(tag).toHaveClass('text-[#FFFFFF]') + expect(tag).toHaveTextContent('Airweave') + + act(() => + root.render( + + ) + ) + + const lightTag = host.querySelector('[data-workflow-brand-tag]') + expect(lightTag).toHaveStyle({ background: '#FFFFFF' }) + expect(lightTag).toHaveClass('text-[#000000]') + }) +}) 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/edges/workflow-edge-view-mount.test.tsx b/apps/sim/lib/workflows/edges/workflow-edge-view-mount.test.tsx new file mode 100644 index 00000000000..aca4987f2c5 --- /dev/null +++ b/apps/sim/lib/workflows/edges/workflow-edge-view-mount.test.tsx @@ -0,0 +1,75 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { WorkflowEdgeView, type WorkflowEdgeViewProps } from '@sim/workflow-renderer' +import { createRoot, type Root } from 'react-dom/client' +import { Position } from 'reactflow' +import { afterEach, describe, expect, it } from 'vitest' + +const mountedHosts = new Set() +const mountedRoots = new Set() + +function renderEdge(overrides: Partial = {}) { + const host = document.createElement('div') + document.body.appendChild(host) + mountedHosts.add(host) + + const root = createRoot(host) + mountedRoots.add(root) + act(() => { + root.render( + + ) + }) + + return { + path: host.querySelector('.react-flow__edge-path'), + } +} + +afterEach(() => { + act(() => { + for (const root of mountedRoots) root.unmount() + }) + mountedRoots.clear() + for (const host of mountedHosts) { + host.remove() + } + mountedHosts.clear() +}) + +describe('WorkflowEdgeView', () => { + it('matches active block silhouettes while the workflow is running', () => { + const { path } = renderEdge({ runStatus: 'success', isWorkflowRunning: true }) + + expect(path?.style.stroke).toBe('var(--text-secondary)') + }) + + it('restores the canvas success color after execution stops', () => { + const { path } = renderEdge({ runStatus: 'success', isWorkflowRunning: false }) + + expect(path?.style.stroke).toBe('var(--border-success)') + }) + + it('keeps execution errors distinct during an active run', () => { + const { path } = renderEdge({ runStatus: 'error', isWorkflowRunning: true }) + + expect(path?.style.stroke).toBe('var(--text-error)') + }) +}) diff --git a/apps/sim/lib/workflows/persistence/utils.test.ts b/apps/sim/lib/workflows/persistence/utils.test.ts index e0fb92ded00..7d1156ee615 100644 --- a/apps/sim/lib/workflows/persistence/utils.test.ts +++ b/apps/sim/lib/workflows/persistence/utils.test.ts @@ -362,6 +362,7 @@ describe('Database Helpers', () => { name: 'Start Block', position: { x: 100, y: 100 }, enabled: true, + errorEnabled: false, horizontalHandles: true, height: 150, subBlocks: { input: { id: 'input', type: 'short-input' as const, value: 'test' } }, @@ -369,6 +370,7 @@ describe('Database Helpers', () => { data: { parentId: undefined, extent: undefined, width: 350 }, advancedMode: false, triggerMode: false, + locked: undefined, }) expect(result?.edges[0]).toEqual({ 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 17689f68818..c164ed82a84 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/execution/store.ts b/apps/sim/stores/execution/store.ts index 5efa313f021..189de1b7240 100644 --- a/apps/sim/stores/execution/store.ts +++ b/apps/sim/stores/execution/store.ts @@ -67,9 +67,10 @@ export const useExecutionStore = create()((se }, setActiveBlocks: (workflowId, blockIds) => { + const activeBlockIds = new Set(blockIds) set({ workflowExecutions: updatedMap(get().workflowExecutions, workflowId, { - activeBlockIds: new Set(blockIds), + activeBlockIds, }), }) }, diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts index 32146b9472b..29817a60c8c 100644 --- a/apps/sim/stores/modals/search/types.ts +++ b/apps/sim/stores/modals/search/types.ts @@ -85,8 +85,8 @@ export type SearchSection = (typeof SEARCH_SECTIONS)[number] */ export interface PendingConnect { source: { nodeId: string; handleId: string } - screenX: number - screenY: number + /** Canvas-space point where the connection was released. */ + position: { x: number; y: number } } /** diff --git a/apps/sim/stores/workflows/utils.test.ts b/apps/sim/stores/workflows/utils.test.ts index 78c602d2954..2b8fa320b8a 100644 --- a/apps/sim/stores/workflows/utils.test.ts +++ b/apps/sim/stores/workflows/utils.test.ts @@ -9,7 +9,7 @@ import { import type { Edge } from 'reactflow' import { describe, expect, it } from 'vitest' import { normalizeName } from '@/executor/constants' -import { getUniqueBlockName, regenerateBlockIds } from './utils' +import { filterNewEdges, getUniqueBlockName, regenerateBlockIds } from './utils' describe('normalizeName', () => { it.concurrent('should convert to lowercase', () => { @@ -107,6 +107,33 @@ describe('normalizeName', () => { }) }) +describe('filterNewEdges', () => { + const makeEdge = (id: string, sourceHandle: string, targetHandle: string): Edge => ({ + id, + source: 'source', + target: 'target', + sourceHandle, + targetHandle, + }) + + it('treats legacy positioned handles as the same logical connection', () => { + const currentEdges = [makeEdge('legacy', 'source-left', 'target-top')] + const candidates = [ + makeEdge('current', 'source-right', 'target-left'), + makeEdge('legacy-vertical', 'source-bottom', 'target-bottom'), + ] + + expect(filterNewEdges(candidates, currentEdges)).toEqual([]) + }) + + it('keeps semantic routing handles distinct', () => { + const currentEdges = [makeEdge('true-route', 'condition-true', 'target-left')] + const candidates = [makeEdge('false-route', 'condition-false', 'target-left')] + + expect(filterNewEdges(candidates, currentEdges)).toEqual(candidates) + }) +}) + describe('getUniqueBlockName', () => { it('should return "Start" for starter blocks', () => { expect(getUniqueBlockName('Start', {})).toBe('Start') diff --git a/apps/sim/stores/workflows/workflow/store.test.ts b/apps/sim/stores/workflows/workflow/store.test.ts index 43e79b4cac5..a4d086aa1af 100644 --- a/apps/sim/stores/workflows/workflow/store.test.ts +++ b/apps/sim/stores/workflows/workflow/store.test.ts @@ -1045,6 +1045,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 740bf790eaf..8ed45369248 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -786,6 +786,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 7670781748c..1129a05490b 100644 --- a/bun.lock +++ b/bun.lock @@ -664,6 +664,7 @@ "@sim/emcn": "workspace:*", "@sim/tsconfig": "workspace:*", "@sim/utils": "workspace:*", + "@sim/workflow-types": "workspace:*", "@types/react": "^19", "lucide-react": "^0.511.0", "react": "19.2.4", @@ -675,6 +676,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/button/button.tsx b/packages/emcn/src/components/button/button.tsx index 80eebd30722..a80b428c4e9 100644 --- a/packages/emcn/src/components/button/button.tsx +++ b/packages/emcn/src/components/button/button.tsx @@ -21,6 +21,13 @@ const buttonVariants = cva( secondary: 'bg-[var(--brand-secondary)] text-[var(--text-primary)]', tertiary: 'bg-[var(--brand-accent)] text-[var(--text-inverse)] hover-hover:text-[var(--text-inverse)] hover-hover:bg-[var(--brand-accent-hover)] dark:bg-[var(--brand-accent)] dark:hover-hover:bg-[var(--brand-accent-hover)] dark:text-[var(--text-inverse)] dark:hover-hover:text-[var(--text-inverse)]', + /* Fixed brand pairs, matching the workflow type tags rather than the + theme tokens — `graphite` is the Agent tag's fill and ink. Both hold + one value across modes, so neither takes a `dark:` override. */ + graphite: + 'bg-[#3B3B3B] text-[#F8F8F8] hover-hover:text-[#F8F8F8] hover-hover:brightness-125', + graphiteSubtle: + 'bg-[#E6E6E6] text-[#3B3B3B] hover-hover:text-[#3B3B3B] hover-hover:brightness-[0.96]', ghost: 'text-[var(--text-secondary)] hover-hover:text-[var(--text-primary)]', subtle: 'text-[var(--text-body)] hover-hover:text-[var(--text-body)] hover-hover:bg-[var(--surface-4)]', diff --git a/packages/emcn/src/components/chip-tag/chip-tag.tsx b/packages/emcn/src/components/chip-tag/chip-tag.tsx index e69ae59c429..e0cdbce5322 100644 --- a/packages/emcn/src/components/chip-tag/chip-tag.tsx +++ b/packages/emcn/src/components/chip-tag/chip-tag.tsx @@ -1,6 +1,12 @@ 'use client' -import type { ComponentType, HTMLAttributes, MouseEventHandler, ReactNode } from 'react' +import type { + ComponentType, + CSSProperties, + HTMLAttributes, + MouseEventHandler, + ReactNode, +} from 'react' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '../../lib/cn' @@ -24,6 +30,29 @@ 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` — the brand type cue for workflow cards. Pair with `tone`; the + * icon inherits the label colour and remains the non-colour identifier. + * + * These are fixed brand values, not derived ones — do not "correct" a hex + * for contrast or gamut. Every label is one of two inks: `#F8F8F8` when the + * fill is dark, `#1A1A1A` when it is light. `#3B3B3B` appears only as + * `inverse`'s fill, never as text. One value serves both modes; the tones + * carry no `dark:` overrides. + * + * Contrast against the paired ink varies, and two pairs sit under WCAG AA + * (4.5:1) for normal text: `green` at 3.98:1 and `orange` at 3.15:1. These + * are brand decisions rather than oversights. Because the label is short and + * always duplicated by an icon and the block name beside it, the tag is a + * redundant cue rather than the sole carrier of the information — but do not + * reuse either pairing anywhere the label stands alone. + * + * `neutral` is the only tone that is not a solid fill — an unmapped block + * type reads as a white, outlined slot rather than as one more colour in the + * set. Every other tone is fill-only, so it is also the only one whose edge + * depends on the ring rather than on the fill itself. + * - `brand` — a provider-owned integration colour supplied through + * `brandColor`. Pair with `brandForeground` so both the icon and label use + * the same contrast rule as integration tiles elsewhere in the product. * - `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 @@ -39,10 +68,25 @@ const chipTagVariants = cva( 'h-5 gap-[3px] px-1 bg-[var(--surface-6)] text-[var(--text-primary)] dark:bg-[var(--surface-3)]', 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', + brand: '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: '', + ash: '', + orange: '', + blue: '', + green: '', + yellow: '', + }, + brandForeground: { + light: '', + dark: '', + }, }, compoundVariants: [ { @@ -50,8 +94,30 @@ const chipTagVariants = cva( invalid: true, className: 'bg-[var(--badge-error-bg)] text-[var(--text-error)] shadow-none', }, + { + variant: 'workflow', + tone: 'neutral', + /* The only outlined tone. An unmapped block type reads as an empty + slot rather than a colour, so the fill is plain white and an inset + ring — not a border — carries the edge, keeping the tag the same + size as every filled sibling. */ + className: 'bg-[#FFFFFF] text-[#1A1A1A] shadow-[inset_0_0_0_1px_#C3C3C3]', + }, + { variant: 'workflow', tone: 'inverse', className: 'bg-[#3B3B3B] text-[#F8F8F8]' }, + { variant: 'workflow', tone: 'ash', className: 'bg-[#E6E6E6] text-[#1A1A1A]' }, + { variant: 'workflow', tone: 'orange', className: 'bg-[#FF4C00] text-[#F8F8F8]' }, + { variant: 'workflow', tone: 'blue', className: 'bg-[#0062FF] text-[#F8F8F8]' }, + { variant: 'workflow', tone: 'green', className: 'bg-[#188F00] text-[#F8F8F8]' }, + { variant: 'workflow', tone: 'yellow', className: 'bg-[#FFEF08] text-[#1A1A1A]' }, + { variant: 'brand', brandForeground: 'light', className: 'text-[#FFFFFF]' }, + { variant: 'brand', brandForeground: 'dark', className: 'text-[#000000]' }, ], - defaultVariants: { variant: 'mono', invalid: false }, + defaultVariants: { + variant: 'mono', + invalid: false, + tone: 'neutral', + brandForeground: 'light', + }, } ) @@ -65,6 +131,8 @@ export interface ChipTagProps VariantProps { /** Tag content — typically a short label, number, percentage, or recipient. */ children: ReactNode + /** Dynamic provider fill used by the `brand` variant. */ + brandColor?: CSSProperties['background'] /** Icon component rendered before the label. Non-interactive. */ leftIcon?: ChipTagIcon /** @@ -100,8 +168,12 @@ export interface ChipTagProps export function ChipTag({ variant, invalid, + tone, + brandForeground, + brandColor, className, children, + style, leftIcon: LeftIcon, rightIcon: RightIcon, onRightIconClick, @@ -109,11 +181,22 @@ export function ChipTag({ rightIconDisabled, ...props }: ChipTagProps) { - const iconClass = cn('size-[14px] flex-shrink-0', !invalid && 'text-[var(--text-icon)]') + /* `workflow` icons inherit the tone's tinted label colour. The shared + `--text-icon` gray is tuned for this component's light surfaces and would + all but disappear on a tone's deep fill. */ + const iconClass = cn( + 'size-[14px] flex-shrink-0', + !invalid && variant !== 'workflow' && variant !== 'brand' && 'text-[var(--text-icon)]' + ) const interactive = RightIcon != null && onRightIconClick != null + const resolvedStyle = variant === 'brand' ? { ...style, background: brandColor } : style return ( - + {LeftIcon ? : null} {children} {RightIcon ? ( diff --git a/packages/emcn/src/components/chip/chip.tsx b/packages/emcn/src/components/chip/chip.tsx index fb868dd8b18..38a7b71bddb 100644 --- a/packages/emcn/src/components/chip/chip.tsx +++ b/packages/emcn/src/components/chip/chip.tsx @@ -104,6 +104,8 @@ interface ChipBaseProps extends Omit, 'variant variant?: ChipVariant /** Icon component rendered before the label. */ leftIcon?: ChipIcon + /** Custom content rendered before the label. Takes precedence over `leftIcon`. */ + leftAdornment?: ReactNode /** Icon component rendered after the label. */ rightIcon?: ChipIcon children?: ReactNode @@ -117,6 +119,7 @@ interface ChipBaseProps extends Omit, 'variant function ChipContent({ variant, leftIcon: LeftIcon, + leftAdornment, rightIcon: RightIcon, children, }: ChipBaseProps) { @@ -125,7 +128,7 @@ function ChipContent({ const labelClass = cn(chipContentLabelClass, 'flex-1', isInverse && 'text-current') return ( <> - {LeftIcon ? : null} + {leftAdornment ?? (LeftIcon ? : null)} {children != null && children !== false ? ( {children} ) : null} @@ -142,7 +145,19 @@ interface ChipProps * @example {balance} */ const Chip = forwardRef(function Chip( - { className, variant, active, fullWidth, flush, leftIcon, rightIcon, children, type, ...props }, + { + className, + variant, + active, + fullWidth, + flush, + leftIcon, + leftAdornment, + rightIcon, + children, + type, + ...props + }, ref ) { return ( @@ -152,7 +167,12 @@ const Chip = forwardRef(function Chip( className={cn(chipVariants({ variant, active, fullWidth, flush }), className)} {...props} > - + {children} @@ -168,7 +188,18 @@ interface ChipLinkProps * @example Integrations */ const ChipLink = forwardRef(function ChipLink( - { className, variant, active, fullWidth, flush, leftIcon, rightIcon, children, ...props }, + { + className, + variant, + active, + fullWidth, + flush, + leftIcon, + leftAdornment, + rightIcon, + children, + ...props + }, ref ) { return ( @@ -177,7 +208,12 @@ const ChipLink = forwardRef(function ChipLink( className={cn(chipVariants({ variant, active, fullWidth, flush }), className)} {...props} > - + {children} diff --git a/packages/emcn/src/components/index.ts b/packages/emcn/src/components/index.ts index a0ad1930f38..377d8c19e12 100644 --- a/packages/emcn/src/components/index.ts +++ b/packages/emcn/src/components/index.ts @@ -196,6 +196,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 6dc3401cf11..085eba4c346 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, @@ -254,7 +272,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}} @@ -265,14 +286,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), @@ -325,6 +349,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 } /** @@ -341,9 +370,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 169217aba7c..ad51ff609c2 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.511.0", "react": "19.2.4", diff --git a/packages/workflow-renderer/src/dimensions.ts b/packages/workflow-renderer/src/dimensions.ts index 736e95b7727..93013274a5b 100644 --- a/packages/workflow-renderer/src/dimensions.ts +++ b/packages/workflow-renderer/src/dimensions.ts @@ -11,13 +11,41 @@ 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, - NOTE_CONTENT_PADDING: 14, + /** 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, + /** Total vertical padding around the note's content viewport (`p-2`). */ + NOTE_CONTENT_PADDING: 16, NOTE_MIN_CONTENT_HEIGHT: 20, - NOTE_BASE_CONTENT_HEIGHT: 60, + /** Bounded canvas preview, including its `p-2`; full content stays in the editor. */ + NOTE_CONTENT_VIEWPORT_HEIGHT: 176, } as const +/** Keeps note DOM, React Flow bounds, and auto-layout on the same height. */ +export const getNoteBlockHeight = (isEmpty: boolean) => + BLOCK_DIMENSIONS.HEADER_HEIGHT + + (isEmpty + ? BLOCK_DIMENSIONS.NOTE_CONTENT_PADDING + BLOCK_DIMENSIONS.NOTE_MIN_CONTENT_HEIGHT + : BLOCK_DIMENSIONS.NOTE_CONTENT_VIEWPORT_HEIGHT) + export const CONTAINER_DIMENSIONS = { DEFAULT_WIDTH: 500, DEFAULT_HEIGHT: 300, @@ -39,6 +67,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..a46fbad86be 100644 --- a/packages/workflow-renderer/src/edge/workflow-edge-view.tsx +++ b/packages/workflow-renderer/src/edge/workflow-edge-view.tsx @@ -18,6 +18,13 @@ export interface WorkflowEdgeViewProps extends EdgeProps { runStatus: EdgeRunStatus /** Whether `runStatus` came from a preview run (drives success coloring). */ isPreviewRun: boolean + /** Whether the canvas is currently presenting an active workflow run. */ + isWorkflowRunning?: boolean + /** + * Whether either endpoint block is selected on the canvas — brightens the + * edge alongside the selected node. Diff and error colors take priority. + */ + isConnectedToSelection?: boolean } /** @@ -26,9 +33,11 @@ export interface WorkflowEdgeViewProps extends EdgeProps { * @remarks * Edge coloring priority: * 1. Diff status (deleted/new) - for version comparison - * 2. Execution status (success/error) - for run visualization - * 3. Error edge default (red) - for untaken error paths - * 4. Default edge color - normal workflow connections + * 2. Execution error and untaken error paths + * 3. Active canvas execution + * 4. Execution success + * 5. Selected endpoint + * 6. Default edge color */ export function WorkflowEdgeView({ id, @@ -44,6 +53,8 @@ export function WorkflowEdgeView({ diffStatus, runStatus, isPreviewRun, + isWorkflowRunning = false, + isConnectedToSelection = false, }: WorkflowEdgeViewProps) { const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left' @@ -72,14 +83,18 @@ export function WorkflowEdgeView({ opacity = 0.7 } else if (diffStatus === 'new') { color = 'var(--brand-accent)' - } else if (runStatus === 'success') { - // Use green for preview mode, default for canvas execution - color = isPreviewRun ? 'var(--brand-accent)' : 'var(--border-success)' } else if (runStatus === 'error') { color = 'var(--text-error)' } else if (isErrorEdge) { // Error edges that weren't taken stay red color = 'var(--text-error)' + } else if (isWorkflowRunning) { + color = 'var(--text-secondary)' + } else if (runStatus === 'success') { + color = isPreviewRun ? 'var(--brand-accent)' : 'var(--border-success)' + } else if (isConnectedToSelection) { + // Match the selected block ring / swell (`--text-secondary`) + color = 'var(--text-secondary)' } if (isSelected) { @@ -87,19 +102,23 @@ 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, + isWorkflowRunning, + isConnectedToSelection, + ]) return ( <> diff --git a/packages/workflow-renderer/src/index.ts b/packages/workflow-renderer/src/index.ts index 0c6938dc112..4843a32025b 100644 --- a/packages/workflow-renderer/src/index.ts +++ b/packages/workflow-renderer/src/index.ts @@ -1,14 +1,39 @@ 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, SubflowNodeView, type SubflowNodeViewProps, + SubflowStartView, } 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, + WorkflowTypeTag, + type WorkflowTypeTagProps, } 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/lib/tile-icon-color.ts b/packages/workflow-renderer/src/lib/tile-icon-color.ts index ce6ca930a69..1be70748163 100644 --- a/packages/workflow-renderer/src/lib/tile-icon-color.ts +++ b/packages/workflow-renderer/src/lib/tile-icon-color.ts @@ -36,8 +36,13 @@ function perceivedBrightness(color: string): number | null { /** Tiles brighter than this flip their icon foreground to near-black. */ const LIGHT_TILE_THRESHOLD = 0.75 +/** Whether a provider tile needs dark foreground content for legibility. */ +export function isLightTileColor(bgColor: string | null | undefined): boolean { + const brightness = bgColor ? perceivedBrightness(bgColor) : null + return brightness !== null && brightness > LIGHT_TILE_THRESHOLD +} + /** `text-white` on dark/unknown tiles, `text-black` on clearly light tiles. */ export function tileIconColorClass(bgColor: string | null | undefined): string { - const brightness = bgColor ? perceivedBrightness(bgColor) : null - return brightness !== null && brightness > LIGHT_TILE_THRESHOLD ? 'text-black' : 'text-white' + return isLightTileColor(bgColor) ? 'text-black' : 'text-white' } diff --git a/packages/workflow-renderer/src/note/note-block-view.tsx b/packages/workflow-renderer/src/note/note-block-view.tsx index c66369f9e97..61a4c923f55 100644 --- a/packages/workflow-renderer/src/note/note-block-view.tsx +++ b/packages/workflow-renderer/src/note/note-block-view.tsx @@ -1,13 +1,22 @@ -import { memo, type ReactNode } from 'react' +import { memo, type ReactNode, useMemo } from 'react' import remarkBreaks from 'remark-breaks' import { Streamdown } from 'streamdown' import 'streamdown/styles.css' import { cn, handleKeyboardActivation } from '@sim/emcn' import { getEmbedInfo } from '@sim/utils/media-embed' +import { BLOCK_DIMENSIONS, getNoteBlockHeight } from '../dimensions' import { OverflowSpan } from '../lib/overflow-span' +import { useActionMenuSwell } from '../workflow-block/use-action-menu-swell' +import { + WorkflowBlockBorder, + type WorkflowBorderPort, +} from '../workflow-block/workflow-block-border' const EMBED_SCALE = 0.78 const EMBED_INVERSE_SCALE = `${(1 / EMBED_SCALE) * 100}%` +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 /** * Compact markdown renderer for note blocks with tight spacing @@ -202,34 +211,115 @@ export function NoteBlockView({ actionBar, }: NoteBlockViewProps) { const isEmpty = content.trim().length === 0 + const isSelected = hasRing && ringStyles.includes('--text-secondary') + const blockHeight = getNoteBlockHeight(isEmpty) + const showActionMenu = Boolean(actionBar) + const { + rootRef: actionMenuRootRef, + hostRef: actionMenuHostRef, + width: actionMenuWidth, + swellOpen: actionMenuSwellOpen, + contentVisible: actionMenuContentVisible, + setReady: setActionMenuSwellReady, + onFocusCapture: handleActionMenuFocus, + onBlurCapture: handleActionMenuBlur, + } = useActionMenuSwell({ + enabled: showActionMenu, + forceOpen: isSelected, + maxWidth: ACTION_MENU_MAX_WIDTH_PX, + }) + const borderPorts = useMemo( + () => + showActionMenu + ? [ + { + 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, + }, + ] + : [], + [actionMenuSwellOpen, actionMenuWidth, showActionMenu] + ) return ( -
+
+ {showActionMenu && ( + <> +