From 40fc9ca504319663f266f94009a510ee15b8c018 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 10 Jan 2026 11:55:20 -0800 Subject: [PATCH 1/4] improvement(response): only allow singleton --- .../utils/workflow-canvas-helpers.ts | 10 ++++++- .../[workspaceId]/w/[workflowId]/workflow.tsx | 22 ++++++++++---- apps/sim/blocks/blocks/response.ts | 1 + apps/sim/blocks/types.ts | 1 + apps/sim/lib/workflows/triggers/triggers.ts | 30 +++++++++++++++++++ apps/sim/stores/workflows/utils.ts | 4 +++ 6 files changed, 62 insertions(+), 6 deletions(-) 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 a0f2a57722..29347d31ef 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 @@ -23,7 +23,8 @@ interface TriggerValidationResult { } /** - * Validates that pasting/duplicating trigger blocks won't violate constraints. + * Validates that pasting/duplicating blocks won't violate constraints. + * Checks both trigger constraints and single-instance block constraints. * Returns validation result with error message if invalid. */ export function validateTriggerPaste( @@ -43,6 +44,13 @@ export function validateTriggerPaste( return { isValid: false, message } } } + + const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(existingBlocks, block.type) + if (singleInstanceIssue) { + const actionText = action === 'paste' ? 'paste' : 'duplicate' + const message = `A workflow can only have one ${singleInstanceIssue.blockName} block. ${action === 'paste' ? 'Please remove the existing one before pasting.' : `Cannot ${actionText}.`}` + return { isValid: false, message } + } } return { isValid: true } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index ef25a7ded7..03764beace 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -1129,17 +1129,18 @@ const WorkflowContent = React.memo(() => { ) /** - * Checks if adding a trigger block would violate constraints and shows notification if so. + * Checks if adding a block would violate constraints (triggers or single-instance blocks) + * and shows notification if so. * @returns true if validation failed (caller should return early), false if ok to proceed */ const checkTriggerConstraints = useCallback( (blockType: string): boolean => { - const issue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType) - if (issue) { + const triggerIssue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType) + if (triggerIssue) { const message = - issue.issue === 'legacy' + triggerIssue.issue === 'legacy' ? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.' - : `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before adding a new one.` + : `A workflow can only have one ${triggerIssue.triggerName} trigger block. Please remove the existing one before adding a new one.` addNotification({ level: 'error', message, @@ -1147,6 +1148,17 @@ const WorkflowContent = React.memo(() => { }) return true } + + const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue(blocks, blockType) + if (singleInstanceIssue) { + addNotification({ + level: 'error', + message: `A workflow can only have one ${singleInstanceIssue.blockName} block. Please remove the existing one before adding a new one.`, + workflowId: activeWorkflowId || undefined, + }) + return true + } + return false }, [blocks, addNotification, activeWorkflowId] diff --git a/apps/sim/blocks/blocks/response.ts b/apps/sim/blocks/blocks/response.ts index f8be9f687e..b5da9365e8 100644 --- a/apps/sim/blocks/blocks/response.ts +++ b/apps/sim/blocks/blocks/response.ts @@ -17,6 +17,7 @@ export const ResponseBlock: BlockConfig = { category: 'blocks', bgColor: '#2F55FF', icon: ResponseIcon, + singleInstance: true, subBlocks: [ { id: 'dataMode', diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 28605a440d..a9cac75e19 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -320,6 +320,7 @@ export interface BlockConfig { subBlocks: SubBlockConfig[] triggerAllowed?: boolean authMode?: AuthMode + singleInstance?: boolean tools: { access: string[] config?: { diff --git a/apps/sim/lib/workflows/triggers/triggers.ts b/apps/sim/lib/workflows/triggers/triggers.ts index dfb5601d2c..9af67a0390 100644 --- a/apps/sim/lib/workflows/triggers/triggers.ts +++ b/apps/sim/lib/workflows/triggers/triggers.ts @@ -592,4 +592,34 @@ export class TriggerUtils { const parentWithType = parent as T & { type?: string } return parentWithType.type === 'loop' || parentWithType.type === 'parallel' } + + static isSingleInstanceBlockType(blockType: string): boolean { + const blockConfig = getBlock(blockType) + return blockConfig?.singleInstance === true + } + + static wouldViolateSingleInstanceBlock( + blocks: T[] | Record, + blockType: string + ): boolean { + if (!TriggerUtils.isSingleInstanceBlockType(blockType)) { + return false + } + + const blockArray = Array.isArray(blocks) ? blocks : Object.values(blocks) + return blockArray.some((block) => block.type === blockType) + } + + static getSingleInstanceBlockIssue( + blocks: T[] | Record, + blockType: string + ): { issue: 'duplicate'; blockName: string } | null { + if (!TriggerUtils.wouldViolateSingleInstanceBlock(blocks, blockType)) { + return null + } + + const blockConfig = getBlock(blockType) + const blockName = blockConfig?.name || blockType + return { issue: 'duplicate', blockName } + } } diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index ac0f529870..2caadeea1a 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -41,6 +41,10 @@ export function getUniqueBlockName(baseName: string, existingBlocks: Record Date: Sat, 10 Jan 2026 12:05:21 -0800 Subject: [PATCH 2/4] respect singleton triggers and blocks in copilot --- .../tools/server/workflow/edit-workflow.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts index 127ec1029b..07bac4194a 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -11,6 +11,7 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { isValidKey } from '@/lib/workflows/sanitization/key-validation' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' +import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { getAllBlocks, getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { EDGE, normalizeName } from '@/executor/constants' @@ -62,6 +63,8 @@ type SkippedItemType = | 'invalid_subflow_parent' | 'nested_subflow_not_allowed' | 'duplicate_block_name' + | 'duplicate_trigger' + | 'duplicate_single_instance_block' /** * Represents an item that was skipped during operation application @@ -1775,6 +1778,34 @@ function applyOperationsToWorkflowState( break } + const triggerIssue = TriggerUtils.getTriggerAdditionIssue(modifiedState.blocks, params.type) + if (triggerIssue) { + logSkippedItem(skippedItems, { + type: 'duplicate_trigger', + operationType: 'add', + blockId: block_id, + reason: `Cannot add ${triggerIssue.triggerName} - a workflow can only have one`, + details: { requestedType: params.type, issue: triggerIssue.issue }, + }) + break + } + + // Check single-instance block constraints (e.g., Response block) + const singleInstanceIssue = TriggerUtils.getSingleInstanceBlockIssue( + modifiedState.blocks, + params.type + ) + if (singleInstanceIssue) { + logSkippedItem(skippedItems, { + type: 'duplicate_single_instance_block', + operationType: 'add', + blockId: block_id, + reason: `Cannot add ${singleInstanceIssue.blockName} - a workflow can only have one`, + details: { requestedType: params.type }, + }) + break + } + // Create new block with proper structure const newBlock = createBlockFromParams( block_id, From c07dbfa06afab71cb74bffe2376ef50063f2bb5c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 10 Jan 2026 12:08:55 -0800 Subject: [PATCH 3/4] don't show dup button for response --- .../workflow-block/components/action-bar/action-bar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx index e861a7e71e..67de1760bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx @@ -87,8 +87,8 @@ export const ActionBar = memo( const userPermissions = useUserPermissionsContext() - // Check for start_trigger (unified start block) - prevent duplication but allow deletion const isStartBlock = blockType === 'starter' || blockType === 'start_trigger' + const isResponseBlock = blockType === 'response' const isNoteBlock = blockType === 'note' /** @@ -140,7 +140,7 @@ export const ActionBar = memo( )} - {!isStartBlock && ( + {!isStartBlock && !isResponseBlock && (