From 8740566f6a1cf0ff2094ec121b36a64e7dd005be Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 17 Jan 2026 12:17:55 -0800 Subject: [PATCH 01/11] fix(block-resolver): path lookup check (#2869) * fix(block-resolver): path lookup check * remove comments --- .../variables/resolvers/block.test.ts | 58 ++++++++++++-- .../sim/executor/variables/resolvers/block.ts | 76 ++++++++++++++++++- 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/apps/sim/executor/variables/resolvers/block.test.ts b/apps/sim/executor/variables/resolvers/block.test.ts index 83e6cf1a73..dac00ee0b0 100644 --- a/apps/sim/executor/variables/resolvers/block.test.ts +++ b/apps/sim/executor/variables/resolvers/block.test.ts @@ -6,10 +6,14 @@ import type { ResolutionContext } from './reference' vi.mock('@sim/logger', () => loggerMock) -/** - * Creates a minimal workflow for testing. - */ -function createTestWorkflow(blocks: Array<{ id: string; name?: string; type?: string }> = []) { +function createTestWorkflow( + blocks: Array<{ + id: string + name?: string + type?: string + outputs?: Record + }> = [] +) { return { version: '1.0', blocks: blocks.map((b) => ({ @@ -17,7 +21,7 @@ function createTestWorkflow(blocks: Array<{ id: string; name?: string; type?: st position: { x: 0, y: 0 }, config: { tool: b.type ?? 'function', params: {} }, inputs: {}, - outputs: {}, + outputs: b.outputs ?? {}, metadata: { id: b.type ?? 'function', name: b.name ?? b.id }, enabled: true, })), @@ -126,7 +130,7 @@ describe('BlockResolver', () => { expect(resolver.resolve('', ctx)).toBe(2) }) - it.concurrent('should return undefined for non-existent path', () => { + it.concurrent('should return undefined for non-existent path when no schema defined', () => { const workflow = createTestWorkflow([{ id: 'source' }]) const resolver = new BlockResolver(workflow) const ctx = createTestContext('current', { @@ -136,6 +140,48 @@ describe('BlockResolver', () => { expect(resolver.resolve('', ctx)).toBeUndefined() }) + it.concurrent('should throw error for path not in output schema', () => { + const workflow = createTestWorkflow([ + { + id: 'source', + outputs: { + validField: { type: 'string', description: 'A valid field' }, + nested: { + child: { type: 'number', description: 'Nested child' }, + }, + }, + }, + ]) + const resolver = new BlockResolver(workflow) + const ctx = createTestContext('current', { + source: { validField: 'value', nested: { child: 42 } }, + }) + + expect(() => resolver.resolve('', ctx)).toThrow( + /"invalidField" doesn't exist on block "source"/ + ) + expect(() => resolver.resolve('', ctx)).toThrow(/Available fields:/) + }) + + it.concurrent('should return undefined for path in schema but missing in data', () => { + const workflow = createTestWorkflow([ + { + id: 'source', + outputs: { + requiredField: { type: 'string', description: 'Always present' }, + optionalField: { type: 'string', description: 'Sometimes missing' }, + }, + }, + ]) + const resolver = new BlockResolver(workflow) + const ctx = createTestContext('current', { + source: { requiredField: 'value' }, + }) + + expect(resolver.resolve('', ctx)).toBe('value') + expect(resolver.resolve('', ctx)).toBeUndefined() + }) + it.concurrent('should return undefined for non-existent block', () => { const workflow = createTestWorkflow([{ id: 'existing' }]) const resolver = new BlockResolver(workflow) diff --git a/apps/sim/executor/variables/resolvers/block.ts b/apps/sim/executor/variables/resolvers/block.ts index 4c46b0d299..7786a27d6d 100644 --- a/apps/sim/executor/variables/resolvers/block.ts +++ b/apps/sim/executor/variables/resolvers/block.ts @@ -9,14 +9,75 @@ import { type ResolutionContext, type Resolver, } from '@/executor/variables/resolvers/reference' -import type { SerializedWorkflow } from '@/serializer/types' +import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' + +function isPathInOutputSchema( + outputs: Record | undefined, + pathParts: string[] +): boolean { + if (!outputs || pathParts.length === 0) { + return true + } + + let current: any = outputs + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i] + + if (/^\d+$/.test(part)) { + continue + } + + if (current === null || current === undefined) { + return false + } + + if (part in current) { + current = current[part] + continue + } + + if (current.properties && part in current.properties) { + current = current.properties[part] + continue + } + + if (current.type === 'array' && current.items) { + if (current.items.properties && part in current.items.properties) { + current = current.items.properties[part] + continue + } + if (part in current.items) { + current = current.items[part] + continue + } + } + + if ('type' in current && typeof current.type === 'string') { + if (!current.properties && !current.items) { + return false + } + } + + return false + } + + return true +} + +function getSchemaFieldNames(outputs: Record | undefined): string[] { + if (!outputs) return [] + return Object.keys(outputs) +} export class BlockResolver implements Resolver { private nameToBlockId: Map + private blockById: Map constructor(private workflow: SerializedWorkflow) { this.nameToBlockId = new Map() + this.blockById = new Map() for (const block of workflow.blocks) { + this.blockById.set(block.id, block) if (block.metadata?.name) { this.nameToBlockId.set(normalizeName(block.metadata.name), block.id) } @@ -47,7 +108,9 @@ export class BlockResolver implements Resolver { return undefined } + const block = this.blockById.get(blockId) const output = this.getBlockOutput(blockId, context) + if (output === undefined) { return undefined } @@ -63,9 +126,6 @@ export class BlockResolver implements Resolver { return result } - // If failed, check if we should try backwards compatibility fallback - const block = this.workflow.blocks.find((b) => b.id === blockId) - // Response block backwards compatibility: // Old: -> New: // Only apply fallback if: @@ -108,6 +168,14 @@ export class BlockResolver implements Resolver { } } + const schemaFields = getSchemaFieldNames(block?.outputs) + if (schemaFields.length > 0 && !isPathInOutputSchema(block?.outputs, pathParts)) { + throw new Error( + `"${pathParts.join('.')}" doesn't exist on block "${blockName}". ` + + `Available fields: ${schemaFields.join(', ')}` + ) + } + return undefined } From f6960a4bd4c09cab1a19958a188dcc513b720a52 Mon Sep 17 00:00:00 2001 From: Waleed Date: Sat, 17 Jan 2026 12:43:22 -0800 Subject: [PATCH 02/11] fix(wand): improved flickering for invalid JSON icon while streaming (#2868) --- .../sub-block/components/code/code.tsx | 30 +++++--------- .../editor/components/sub-block/sub-block.tsx | 40 ++++++++++--------- 2 files changed, 30 insertions(+), 40 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx index f778c9c4aa..2324535039 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx @@ -307,25 +307,18 @@ export function Code({ ? getDefaultValueString() : storeValue - const lastValidationStatus = useRef(true) - useEffect(() => { if (!onValidationChange) return - const nextStatus = shouldValidateJson ? isValidJson : true - if (lastValidationStatus.current === nextStatus) { - return - } - - lastValidationStatus.current = nextStatus + const isValid = !shouldValidateJson || isValidJson - if (!shouldValidateJson) { - onValidationChange(nextStatus) + if (isValid) { + onValidationChange(true) return } const timeoutId = setTimeout(() => { - onValidationChange(nextStatus) + onValidationChange(false) }, 150) return () => clearTimeout(timeoutId) @@ -337,7 +330,7 @@ export function Code({ } handleStreamChunkRef.current = (chunk: string) => { - setCode((prev) => prev + chunk) + setCode((prev: string) => prev + chunk) } handleGeneratedContentRef.current = (generatedCode: string) => { @@ -434,12 +427,12 @@ export function Code({ ` document.body.appendChild(tempContainer) - lines.forEach((line) => { + lines.forEach((line: string) => { const lineDiv = document.createElement('div') if (line.includes('<') && line.includes('>')) { const parts = line.split(/(<[^>]+>)/g) - parts.forEach((part) => { + parts.forEach((part: string) => { const span = document.createElement('span') span.textContent = part lineDiv.appendChild(span) @@ -472,7 +465,6 @@ export function Code({ } }, [code]) - // Event Handlers /** * Handles drag-and-drop events for inserting reference tags into the code editor. * @param e - The drag event @@ -500,7 +492,6 @@ export function Code({ textarea.selectionStart = newCursorPosition textarea.selectionEnd = newCursorPosition - // Show tag dropdown after cursor is positioned setShowTags(true) if (data.connectionData?.sourceBlockId) { setActiveSourceBlockId(data.connectionData.sourceBlockId) @@ -559,7 +550,6 @@ export function Code({ } } - // Helper Functions /** * Determines whether a `<...>` segment should be highlighted as a reference. * @param part - The code segment to check @@ -596,7 +586,6 @@ export function Code({ return accessiblePrefixes.has(normalizedPrefix) } - // Expose wand control handlers to parent via ref useImperativeHandle( wandControlRef, () => ({ @@ -617,7 +606,7 @@ export function Code({ const numbers: ReactElement[] = [] let lineNumber = 1 - visualLineHeights.forEach((height) => { + visualLineHeights.forEach((height: number) => { const isActive = lineNumber === activeLineNumber numbers.push(
{ + onValueChange={(newCode: string) => { if (!isAiStreaming && !isPreview && !disabled && !readOnly) { hasEditedSinceFocusRef.current = true setCode(newCode) @@ -761,7 +750,6 @@ export function Code({ }} onFocus={() => { hasEditedSinceFocusRef.current = false - // Show tag dropdown on focus when code is empty if (!isPreview && !disabled && !readOnly && code.trim() === '') { setShowTags(true) setCursorPosition(0) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 9379ed8c20..b95d5f1a45 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -207,21 +207,21 @@ const renderLabel = (
{showWand && ( @@ -239,9 +239,11 @@ const renderLabel = ( wandState.onSearchChange(e.target.value)} + onChange={(e: React.ChangeEvent) => + wandState.onSearchChange(e.target.value) + } onBlur={wandState.onSearchBlur} - onKeyDown={(e) => { + onKeyDown={(e: React.KeyboardEvent) => { if ( e.key === 'Enter' && wandState.searchQuery.trim() && @@ -262,11 +264,11 @@ const renderLabel = (
- {/* Authentication */} + {/* Access */}
- setAuthScheme(v as AuthScheme)} - placeholder='Select authentication...' - /> + onValueChange={(value) => setAuthScheme(value as AuthScheme)} + > + API Key + Public +

{authScheme === 'none' ? 'Anyone can call this agent without authentication' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 8b56c5ab43..30a2bd79f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -424,7 +424,7 @@ export function ChatDeploy({ > Cancel - 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 8c8897fbd3..b986ff6546 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 @@ -7,6 +7,7 @@ import { cn } from '@/lib/core/utils/cn' import { getBaseUrl } from '@/lib/core/utils/urls' import { createMcpToolId } from '@/lib/mcp/utils' import { getProviderIdFromServiceId } from '@/lib/oauth' +import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' import { buildCanonicalIndex, evaluateSubBlockCondition, @@ -28,11 +29,7 @@ import { shouldSkipBlockRender, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils' import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' -import { - BLOCK_DIMENSIONS, - HANDLE_POSITIONS, - useBlockDimensions, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions' +import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions' import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types' import { getDependsOnFields } from '@/blocks/utils' import { useKnowledgeBase } from '@/hooks/kb/use-knowledge' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts index b80f7749a8..31ebdc27a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/index.ts @@ -1,18 +1,14 @@ -export { - clearDragHighlights, - computeClampedPositionUpdates, - computeParentUpdateEntries, - getClampedPositionForNode, - isInEditableElement, - resolveParentChildSelectionConflicts, - validateTriggerPaste, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-canvas-helpers' export { useFloatBoundarySync, useFloatDrag, useFloatResize } from './float' +export { useAccessibleReferencePrefixes } from './use-accessible-reference-prefixes' export { useAutoLayout } from './use-auto-layout' -export { BLOCK_DIMENSIONS, useBlockDimensions } from './use-block-dimensions' +export { useBlockDimensions } from './use-block-dimensions' +export { useBlockOutputFields } from './use-block-output-fields' export { useBlockVisual } from './use-block-visual' +export { useCanvasContextMenu } from './use-canvas-context-menu' export { type CurrentWorkflow, useCurrentWorkflow } from './use-current-workflow' -export { calculateContainerDimensions, useNodeUtilities } from './use-node-utilities' +export { useNodeUtilities } from './use-node-utilities' export { usePreventZoom } from './use-prevent-zoom' export { useScrollManagement } from './use-scroll-management' +export { useShiftSelectionLock } from './use-shift-selection-lock' +export { useWand, type WandConfig } from './use-wand' export { useWorkflowExecution } from './use-workflow-execution' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts index e8c7f9f154..aaa2025423 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions.ts @@ -2,9 +2,6 @@ import { useEffect, useRef } from 'react' import { useUpdateNodeInternals } from 'reactflow' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -// Re-export for backwards compatibility -export { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '@/lib/workflows/blocks/block-dimensions' - interface BlockDimensions { width: number height: number diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index e137506618..06329a6b71 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -2,107 +2,15 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' import { useReactFlow } from 'reactflow' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' -import { getBlock } from '@/blocks/registry' +import { + calculateContainerDimensions, + clampPositionToContainer, + estimateBlockDimensions, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' const logger = createLogger('NodeUtilities') -/** - * Estimates block dimensions based on block type. - * Uses subblock count to estimate height for blocks that haven't been measured yet. - * - * @param blockType - The type of block (e.g., 'condition', 'agent') - * @returns Estimated width and height for the block - */ -export function estimateBlockDimensions(blockType: string): { width: number; height: number } { - const blockConfig = getBlock(blockType) - const subBlockCount = blockConfig?.subBlocks?.length ?? 3 - // Many subblocks are conditionally rendered (advanced mode, provider-specific, etc.) - // Use roughly half the config count as a reasonable estimate, capped between 3-7 rows - const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7)) - const hasErrorRow = blockType !== 'starter' && blockType !== 'response' ? 1 : 0 - - const height = - BLOCK_DIMENSIONS.HEADER_HEIGHT + - BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING + - (estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT - - return { - width: BLOCK_DIMENSIONS.FIXED_WIDTH, - height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT), - } -} - -/** - * Clamps a position to keep a block fully inside a container's content area. - * Content area starts after the header and padding, and ends before the right/bottom padding. - * - * @param position - Raw position relative to container origin - * @param containerDimensions - Container width and height - * @param blockDimensions - Block width and height - * @returns Clamped position that keeps block inside content area - */ -export function clampPositionToContainer( - position: { x: number; y: number }, - containerDimensions: { width: number; height: number }, - blockDimensions: { width: number; height: number } -): { x: number; y: number } { - const { width: containerWidth, height: containerHeight } = containerDimensions - const { width: blockWidth, height: blockHeight } = blockDimensions - - // Content area bounds (where blocks can be placed) - const minX = CONTAINER_DIMENSIONS.LEFT_PADDING - const minY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING - const maxX = containerWidth - CONTAINER_DIMENSIONS.RIGHT_PADDING - blockWidth - const maxY = containerHeight - CONTAINER_DIMENSIONS.BOTTOM_PADDING - blockHeight - - return { - x: Math.max(minX, Math.min(position.x, Math.max(minX, maxX))), - y: Math.max(minY, Math.min(position.y, Math.max(minY, maxY))), - } -} - -/** - * Calculates container dimensions based on child block positions. - * Single source of truth for container sizing - ensures consistency between - * live drag updates and final dimension calculations. - * - * @param childPositions - Array of child positions with their dimensions - * @returns Calculated width and height for the container - */ -export function calculateContainerDimensions( - childPositions: Array<{ x: number; y: number; width: number; height: number }> -): { width: number; height: number } { - if (childPositions.length === 0) { - return { - width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - } - } - - let maxRight = 0 - let maxBottom = 0 - - for (const child of childPositions) { - maxRight = Math.max(maxRight, child.x + child.width) - maxBottom = Math.max(maxBottom, child.y + child.height) - } - - const width = Math.max( - CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING - ) - const height = Math.max( - CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - CONTAINER_DIMENSIONS.HEADER_HEIGHT + - CONTAINER_DIMENSIONS.TOP_PADDING + - maxBottom + - CONTAINER_DIMENSIONS.BOTTOM_PADDING - ) - - return { width, height } -} - /** * Hook providing utilities for node position, hierarchy, and dimension calculations */ @@ -138,7 +46,6 @@ export function useNodeUtilities(blocks: Record) { } } - // Prefer deterministic height published by the block component; fallback to estimate if (block.height) { return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, @@ -146,7 +53,6 @@ export function useNodeUtilities(blocks: Record) { } } - // Use shared estimation utility for blocks without measured height return estimateBlockDimensions(block.type) }, [blocks, isContainerType] @@ -230,8 +136,6 @@ export function useNodeUtilities(blocks: Record) { const parentPos = getNodeAbsolutePosition(parentId) - // Child positions are stored relative to the content area (after header and padding) - // Add these offsets when calculating absolute position const headerHeight = 50 const leftPadding = 16 const topPadding = 16 @@ -314,7 +218,6 @@ export function useNodeUtilities(blocks: Record) { }) .map((n) => ({ loopId: n.id, - // Return absolute position so callers can compute relative placement correctly loopPosition: getNodeAbsolutePosition(n.id), dimensions: { width: n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, @@ -449,7 +352,6 @@ export function useNodeUtilities(blocks: Record) { return absPos } - // Use known defaults per node type without type casting const isSubflow = node.type === 'subflowNode' const width = isSubflow ? typeof node.data?.width === 'number' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock.ts new file mode 100644 index 0000000000..d50ec82551 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-shift-selection-lock.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useState } from 'react' + +interface UseShiftSelectionLockProps { + isHandMode: boolean +} + +interface UseShiftSelectionLockResult { + /** Whether a shift-selection is currently active (locked in until mouseup) */ + isShiftSelecting: boolean + /** Handler to attach to canvas mousedown */ + handleCanvasMouseDown: (event: React.MouseEvent) => void + /** Computed ReactFlow props based on current selection state */ + selectionProps: { + selectionOnDrag: boolean + panOnDrag: [number, number] | false + selectionKeyCode: string | null + } +} + +/** + * Locks shift-selection mode from mousedown to mouseup. + * Prevents selection from canceling when shift is released mid-drag. + */ +export function useShiftSelectionLock({ + isHandMode, +}: UseShiftSelectionLockProps): UseShiftSelectionLockResult { + const [isShiftSelecting, setIsShiftSelecting] = useState(false) + + const handleCanvasMouseDown = useCallback( + (event: React.MouseEvent) => { + if (!event.shiftKey) return + + const target = event.target as HTMLElement | null + const isPaneTarget = Boolean(target?.closest('.react-flow__pane, .react-flow__selectionpane')) + + if (isPaneTarget && isHandMode) { + setIsShiftSelecting(true) + } + + if (isPaneTarget) { + event.preventDefault() + window.getSelection()?.removeAllRanges() + } + }, + [isHandMode] + ) + + useEffect(() => { + if (!isShiftSelecting) return + + const handleMouseUp = () => setIsShiftSelecting(false) + window.addEventListener('mouseup', handleMouseUp) + return () => window.removeEventListener('mouseup', handleMouseUp) + }, [isShiftSelecting]) + + const selectionProps = { + selectionOnDrag: !isHandMode || isShiftSelecting, + panOnDrag: (isHandMode && !isShiftSelecting ? [0, 1] : false) as [number, number] | false, + selectionKeyCode: isShiftSelecting ? null : 'Shift', + } + + return { isShiftSelecting, handleCanvasMouseDown, selectionProps } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/index.ts index adf8b5f8a3..d2845af28b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/index.ts @@ -1,3 +1,5 @@ export * from './auto-layout-utils' export * from './block-ring-utils' +export * from './node-position-utils' +export * from './workflow-canvas-helpers' export * from './workflow-execution-utils' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts new file mode 100644 index 0000000000..01068ff111 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts @@ -0,0 +1,95 @@ +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' +import { getBlock } from '@/blocks/registry' + +/** + * Estimates block dimensions based on block type. + * Uses subblock count to estimate height for blocks that haven't been measured yet. + * + * @param blockType - The type of block (e.g., 'condition', 'agent') + * @returns Estimated width and height for the block + */ +export function estimateBlockDimensions(blockType: string): { width: number; height: number } { + const blockConfig = getBlock(blockType) + const subBlockCount = blockConfig?.subBlocks?.length ?? 3 + const estimatedRows = Math.max(3, Math.min(Math.ceil(subBlockCount / 2), 7)) + const hasErrorRow = blockType !== 'starter' && blockType !== 'response' ? 1 : 0 + + const height = + BLOCK_DIMENSIONS.HEADER_HEIGHT + + BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING + + (estimatedRows + hasErrorRow) * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT + + return { + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max(height, BLOCK_DIMENSIONS.MIN_HEIGHT), + } +} + +/** + * Clamps a position to keep a block fully inside a container's content area. + * Content area starts after the header and padding, and ends before the right/bottom padding. + * + * @param position - Raw position relative to container origin + * @param containerDimensions - Container width and height + * @param blockDimensions - Block width and height + * @returns Clamped position that keeps block inside content area + */ +export function clampPositionToContainer( + position: { x: number; y: number }, + containerDimensions: { width: number; height: number }, + blockDimensions: { width: number; height: number } +): { x: number; y: number } { + const { width: containerWidth, height: containerHeight } = containerDimensions + const { width: blockWidth, height: blockHeight } = blockDimensions + + const minX = CONTAINER_DIMENSIONS.LEFT_PADDING + const minY = CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING + const maxX = containerWidth - CONTAINER_DIMENSIONS.RIGHT_PADDING - blockWidth + const maxY = containerHeight - CONTAINER_DIMENSIONS.BOTTOM_PADDING - blockHeight + + return { + x: Math.max(minX, Math.min(position.x, Math.max(minX, maxX))), + y: Math.max(minY, Math.min(position.y, Math.max(minY, maxY))), + } +} + +/** + * Calculates container dimensions based on child block positions. + * Single source of truth for container sizing - ensures consistency between + * live drag updates and final dimension calculations. + * + * @param childPositions - Array of child positions with their dimensions + * @returns Calculated width and height for the container + */ +export function calculateContainerDimensions( + childPositions: Array<{ x: number; y: number; width: number; height: number }> +): { width: number; height: number } { + if (childPositions.length === 0) { + return { + width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + } + + let maxRight = 0 + let maxBottom = 0 + + for (const child of childPositions) { + maxRight = Math.max(maxRight, child.x + child.width) + maxBottom = Math.max(maxBottom, child.y + child.height) + } + + const width = Math.max( + CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING + ) + const height = Math.max( + CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + CONTAINER_DIMENSIONS.HEADER_HEIGHT + + CONTAINER_DIMENSIONS.TOP_PADDING + + maxBottom + + CONTAINER_DIMENSIONS.BOTTOM_PADDING + ) + + return { width, height } +} 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 a6d6ea9136..7f24907c47 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 @@ -1,7 +1,7 @@ import type { Edge, Node } from 'reactflow' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' -import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' +import { clampPositionToContainer } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils' import type { BlockState } from '@/stores/workflows/workflow/types' /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index f2a0db4d7a..a32bbb96aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -42,22 +42,23 @@ import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp import { WorkflowControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-controls/workflow-controls' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' import { - clearDragHighlights, - computeClampedPositionUpdates, - getClampedPositionForNode, - isInEditableElement, - resolveParentChildSelectionConflicts, useAutoLayout, + useCanvasContextMenu, useCurrentWorkflow, useNodeUtilities, - validateTriggerPaste, + useShiftSelectionLock, } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks' -import { useCanvasContextMenu } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-canvas-context-menu' import { calculateContainerDimensions, clampPositionToContainer, + clearDragHighlights, + computeClampedPositionUpdates, estimateBlockDimensions, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' + getClampedPositionForNode, + isInEditableElement, + resolveParentChildSelectionConflicts, + validateTriggerPaste, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useSocket } from '@/app/workspace/providers/socket-provider' import { getBlock } from '@/blocks' import { isAnnotationOnlyBlock } from '@/executor/constants' @@ -235,6 +236,7 @@ const WorkflowContent = React.memo(() => { const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false) const canvasMode = useCanvasModeStore((state) => state.mode) const isHandMode = canvasMode === 'hand' + const { handleCanvasMouseDown, selectionProps } = useShiftSelectionLock({ isHandMode }) const [oauthModal, setOauthModal] = useState<{ provider: OAuthProvider serviceId: string @@ -264,6 +266,9 @@ const WorkflowContent = React.memo(() => { preparePasteData, hasClipboard, clipboard, + pendingSelection, + setPendingSelection, + clearPendingSelection, } = useWorkflowRegistry( useShallow((state) => ({ workflows: state.workflows, @@ -274,6 +279,9 @@ const WorkflowContent = React.memo(() => { preparePasteData: state.preparePasteData, hasClipboard: state.hasClipboard, clipboard: state.clipboard, + pendingSelection: state.pendingSelection, + setPendingSelection: state.setPendingSelection, + clearPendingSelection: state.clearPendingSelection, })) ) @@ -441,9 +449,6 @@ const WorkflowContent = React.memo(() => { new Map() ) - /** Stores node IDs to select on next derivedNodes sync (for paste/duplicate operations). */ - const pendingSelectionRef = useRef | null>(null) - /** Re-applies diff markers when blocks change after socket rehydration. */ const blocksRef = useRef(blocks) useEffect(() => { @@ -682,7 +687,7 @@ const WorkflowContent = React.memo(() => { autoConnectEdge?: Edge, triggerMode?: boolean ) => { - pendingSelectionRef.current = new Set([id]) + setPendingSelection([id]) setSelectedEdges(new Map()) const blockData: Record = { ...(data || {}) } @@ -719,7 +724,7 @@ const WorkflowContent = React.memo(() => { ) usePanelEditorStore.getState().setCurrentBlockId(id) }, - [collaborativeBatchAddBlocks, setSelectedEdges] + [collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection] ) const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore( @@ -881,10 +886,7 @@ const WorkflowContent = React.memo(() => { } // Set pending selection before adding blocks - sync effect will apply it (accumulates for rapid pastes) - pendingSelectionRef.current = new Set([ - ...(pendingSelectionRef.current ?? []), - ...pastedBlocksArray.map((b) => b.id), - ]) + setPendingSelection(pastedBlocksArray.map((b) => b.id)) collaborativeBatchAddBlocks( pastedBlocksArray, @@ -894,7 +896,14 @@ const WorkflowContent = React.memo(() => { pasteData.subBlockValues ) }, - [preparePasteData, blocks, addNotification, activeWorkflowId, collaborativeBatchAddBlocks] + [ + preparePasteData, + blocks, + addNotification, + activeWorkflowId, + collaborativeBatchAddBlocks, + setPendingSelection, + ] ) const handleContextPaste = useCallback(() => { @@ -2041,26 +2050,28 @@ const WorkflowContent = React.memo(() => { useEffect(() => { // Check for pending selection (from paste/duplicate), otherwise preserve existing selection - const pendingSelection = pendingSelectionRef.current - pendingSelectionRef.current = null + if (pendingSelection && pendingSelection.length > 0) { + const pendingSet = new Set(pendingSelection) + clearPendingSelection() + + // Apply pending selection and resolve parent-child conflicts + const withSelection = derivedNodes.map((node) => ({ + ...node, + selected: pendingSet.has(node.id), + })) + setDisplayNodes(resolveParentChildSelectionConflicts(withSelection, blocks)) + return + } + // Preserve existing selection state setDisplayNodes((currentNodes) => { - if (pendingSelection) { - // Apply pending selection and resolve parent-child conflicts - const withSelection = derivedNodes.map((node) => ({ - ...node, - selected: pendingSelection.has(node.id), - })) - return resolveParentChildSelectionConflicts(withSelection, blocks) - } - // Preserve existing selection state const selectedIds = new Set(currentNodes.filter((n) => n.selected).map((n) => n.id)) return derivedNodes.map((node) => ({ ...node, selected: selectedIds.has(node.id), })) }) - }, [derivedNodes, blocks]) + }, [derivedNodes, blocks, pendingSelection, clearPendingSelection]) /** Handles ActionBar remove-from-subflow events. */ useEffect(() => { @@ -3010,23 +3021,6 @@ const WorkflowContent = React.memo(() => { usePanelEditorStore.getState().clearCurrentBlock() }, []) - /** Prevents native text selection when starting a shift-drag on the pane. */ - const handleCanvasMouseDown = useCallback((event: React.MouseEvent) => { - if (!event.shiftKey) return - - const target = event.target as HTMLElement | null - if (!target) return - - const isPaneTarget = Boolean(target.closest('.react-flow__pane, .react-flow__selectionpane')) - if (!isPaneTarget) return - - event.preventDefault() - const selection = window.getSelection() - if (selection && selection.rangeCount > 0) { - selection.removeAllRanges() - } - }, []) - /** * Handles node click to select the node in ReactFlow. * Parent-child conflict resolution happens automatically in onNodesChange. @@ -3226,9 +3220,10 @@ const WorkflowContent = React.memo(() => { onPointerMove={handleCanvasPointerMove} onPointerLeave={handleCanvasPointerLeave} elementsSelectable={true} - selectionOnDrag={!isHandMode} + selectionOnDrag={selectionProps.selectionOnDrag} selectionMode={SelectionMode.Partial} - panOnDrag={isHandMode ? [0, 1] : false} + panOnDrag={selectionProps.panOnDrag} + selectionKeyCode={selectionProps.selectionKeyCode} multiSelectionKeyCode={['Meta', 'Control', 'Shift']} nodesConnectable={effectivePermissions.canEdit} nodesDraggable={effectivePermissions.canEdit} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx index e3c5a159d0..77e2c1dcd7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/preview.tsx @@ -19,7 +19,7 @@ import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/componen import { SubflowNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node' import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block' import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge' -import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities' +import { estimateBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { WorkflowPreviewBlock } from '@/app/workspace/[workspaceId]/w/components/preview/components/block' import { WorkflowPreviewSubflow } from '@/app/workspace/[workspaceId]/w/components/preview/components/subflow' import { getBlock } from '@/blocks' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx index 38c2886139..067db3cc9d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx @@ -1078,7 +1078,7 @@ export function AccessControl() { @@ -321,12 +320,7 @@ export function BYOK() { - diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx index f59e970244..e2d85bca6c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/copilot/copilot.tsx @@ -334,7 +334,7 @@ export function Copilot() { Cancel {hasConflicts || hasInvalidKeys ? ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/team-seats.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/team-seats.tsx index 58e8a2acd3..82418420ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/team-seats.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/team-management/components/team-seats/team-seats.tsx @@ -117,7 +117,7 @@ export function TeamSeats({ - diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx index 8cb9efcaba..660389c247 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal.tsx @@ -709,7 +709,7 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr