From faa498d9cb795b120f4779f204921e7c4ac6bc6c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 17:52:19 -0700 Subject: [PATCH 01/14] fix(workspace-forking): stop double-labelling a custom block's inputs, and derive their controls from the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems with how a repointed custom block's inputs render in the sync modal. The field title printed twice. The row wrapper already draws the label and its required marker for every dependent field — `DependentFieldSelector` takes a `title` only to phrase its placeholder and renders a bare combobox. The custom-block branch used `ChipModalField`, which owns a label of its own, so every input showed its name twice. It now renders bare controls like its sibling does. The control was chosen by re-reading the raw field type instead of asking the function that already answers this. `subBlockTypeForField` decides what a Start field becomes on the canvas; the modal had a parallel switch that had already drifted, rendering a `file[]` input — an upload on the canvas — as a plain text box, which would write a bare string into a field expecting file references. `subBlockTypeForField` is now exported and the modal derives from it, so the two cannot disagree about what a field IS; the modal only decides how that kind draws. A file input is explicitly `unsupported` rather than falling through: it renders disabled, saying it is set in the workflow, instead of inviting a value that cannot work. A test walks every type a Start field can declare and asserts the modal's choice follows the canvas's, so a type added later surfaces here rather than silently becoming a text box. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/blocks/custom/build-config.ts | 8 ++- .../custom-block-input-control.test.ts | 23 +++++++ .../fork-sync/custom-block-input-control.ts | 38 ++++++++---- .../components/fork-sync/fork-sync-view.tsx | 61 +++++++++++++------ 4 files changed, 97 insertions(+), 33 deletions(-) diff --git a/apps/sim/blocks/custom/build-config.ts b/apps/sim/blocks/custom/build-config.ts index e6b68cb35cc..fc458fdd391 100644 --- a/apps/sim/blocks/custom/build-config.ts +++ b/apps/sim/blocks/custom/build-config.ts @@ -96,7 +96,13 @@ export function assembleCustomBlockInputMapping(params: Record) } /** Map a Start input field type to the editor sub-block type used to collect it. */ -function subBlockTypeForField(fieldType: string): SubBlockType { +/** + * The sub-block a Start input field becomes on the canvas. Exported so any surface that has to + * render or reason about a custom block's inputs derives the field's KIND from here instead of + * re-deriving it — the fork sync modal renders its own controls but must agree with this about + * what each field is. + */ +export function subBlockTypeForField(fieldType: string): SubBlockType { switch (fieldType) { case 'boolean': return 'switch' diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts index ef3c141c8ed..437b492fdb4 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { subBlockTypeForField } from '@/blocks/custom/build-config' import { CUSTOM_BLOCK_BOOLEAN_FALSE, CUSTOM_BLOCK_BOOLEAN_TRUE, @@ -21,6 +22,28 @@ describe('customBlockInputControl', () => { expect(customBlockInputControl('number')).toBe('input') }) + it('refuses to offer a file input rather than rendering a text box for it', () => { + // `file[]` is an upload on the canvas. A text box would write a plain string into a field + // that expects file references — worse than not offering it, because it looks configured. + expect(customBlockInputControl('file[]')).toBe('unsupported') + }) + + it('stays in step with the canvas mapping for every declared field type', () => { + // The union a Start field can declare. Deriving from `subBlockTypeForField` means a type + // added there surfaces here instead of silently falling through to a text box — which is + // exactly how `file[]` came to be mis-rendered. + const byCanvasKind = { + switch: 'switch', + code: 'textarea', + 'file-upload': 'unsupported', + } as const + + for (const fieldType of ['string', 'number', 'boolean', 'object', 'array', 'file[]']) { + const canvasKind = subBlockTypeForField(fieldType) as keyof typeof byCanvasKind + expect(customBlockInputControl(fieldType)).toBe(byCanvasKind[canvasKind] ?? 'input') + } + }) + it('falls back to a plain input for an unknown or absent type', () => { expect(customBlockInputControl('something-new')).toBe('input') expect(customBlockInputControl(undefined)).toBe('input') diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts index 1ac4b301728..1bce31c08c4 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts @@ -1,23 +1,32 @@ +import { subBlockTypeForField } from '@/blocks/custom/build-config' + /** - * Which control the sync modal renders for a repointed custom block's input, derived from the - * field type its Start block declares. + * Which control the sync modal renders for a repointed custom block's input. + * + * Derived from `subBlockTypeForField` — the same function that decides what the field becomes + * on the canvas — rather than re-reading the raw field type. The modal cannot reuse the canvas + * sub-block renderer (that one is bound to the workflow store, by workflow and block id), so it + * draws its own controls; taking the field's KIND from one place is what stops the two drifting + * when a field type is added. Re-deriving it is how `file[]` came to render as a text box. * - * Mirrors `subBlockTypeForField` in `@/blocks/custom/build-config`, which decides the same thing - * for the canvas — a field the user configures here must read and behave the way it will once - * the block is open in the editor. + * `unsupported` is a real outcome, not a fallback: a `file[]` input is an upload on the canvas, + * and there is nothing meaningful to type for it here. A text box would write a plain string + * into a field that expects file references. */ -export type CustomBlockInputControl = 'switch' | 'textarea' | 'input' +export type CustomBlockInputControl = 'switch' | 'textarea' | 'input' | 'unsupported' export function customBlockInputControl(fieldType: string | undefined): CustomBlockInputControl { - switch (fieldType) { - // Stored as a real boolean on the canvas (its sub-block is a `switch`), so it must be - // toggled here rather than typed — a text field would persist the string `'true'`. - case 'boolean': + switch (subBlockTypeForField(fieldType ?? '')) { + // Stored as a real boolean on the canvas, so it must be toggled rather than typed — a text + // field would persist the string `'true'`. + case 'switch': return 'switch' - // Authored as JSON and parsed by the executor before the child receives it. - case 'object': - case 'array': + // A JSON editor on the canvas. The modal has no editor, but the value is the same JSON + // string either way and the executor parses it before the child receives it. + case 'code': return 'textarea' + case 'file-upload': + return 'unsupported' default: return 'input' } @@ -58,3 +67,6 @@ const BOOLEAN_OPTIONAL_OPTIONS = [ export function customBlockBooleanOptions(required: boolean) { return required ? BOOLEAN_VALUE_OPTIONS : BOOLEAN_OPTIONAL_OPTIONS } + +/** Shown in place of a control for a field the sync modal cannot configure. */ +export const CUSTOM_BLOCK_UNSUPPORTED_HINT = 'Set in the workflow — files cannot be configured here' diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index ec914822681..14b5adc01fb 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -6,8 +6,9 @@ import { ChevronDown, Chip, ChipCombobox, - ChipModalField, + ChipInput, ChipSwitch, + ChipTextarea, CollapsibleCard, cn, FieldDivider, @@ -34,6 +35,7 @@ import { } from '@/ee/workspace-forking/components/fork-sync/cleared-refs-list' import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation' import { + CUSTOM_BLOCK_UNSUPPORTED_HINT, customBlockBooleanOptions, customBlockInputControl, } from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control' @@ -221,33 +223,54 @@ function DependentSelector({ const effectiveValue = (f: ForkDependentReconfig) => effectiveValueIn(f, reconfig) if (isCustomBlockInput) { // Not a selector: there is no parent resource to browse and no options to fetch, just the - // target block's own declared input. Rendered as a plain field so the user types the value - // the repointed block should run with. Structured types get a textarea because their value - // is JSON, matching how `subBlockTypeForField` renders them on the canvas. + // target block's own declared input. Renders a BARE control, like `DependentFieldSelector` + // does — the row wrapper above already draws the field's label and required marker, so a + // labelled `ChipModalField` printed the title twice. const setValue = (value: string) => setReconfig((current) => ({ ...current, [dependentKey(field)]: value })) const value = effectiveValue(field) - const shared = { title: field.title, required: field.required } switch (customBlockInputControl(field.fieldType)) { case 'switch': return ( - - - + ) case 'textarea': - return + return ( + setValue(event.target.value)} + rows={3} + placeholder={`Enter ${field.title} as JSON`} + /> + ) + case 'unsupported': + return ( + {}} + disabled + placeholder={CUSTOM_BLOCK_UNSUPPORTED_HINT} + /> + ) default: - return + return ( + setValue(event.target.value)} + placeholder={`Enter ${field.title}`} + /> + ) } } From e2d4049c439bacd84a17f20f7e016540b35af313 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 18:39:23 -0700 Subject: [PATCH 02/14] fix(workspace-forking): resolve a custom block's inputs against the target environment, and stop a re-sync wiping its uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repointed custom block's inputs are configured at sync time, but the modal drew them as bare text fields against no environment at all: - `{{SECRET}}` had no completion, and no way to know which secrets exist in the workspace the value is written INTO. - `` had no completion. The canvas dropdown reads the workflow open in the editor; on the fork settings page there is none, and the workflow that matters is the target's. - A `file[]` input has no control here (it is an upload on the canvas), so it had no stored override — and the block was rebuilt from overrides alone, so every sync silently dropped the target's uploaded files. `WorkflowReferenceScope` lets a surface supply the workflow a reference resolves against. Absent a provider, the hooks read the live editor stores exactly as before, so the canvas is unchanged. The scope splits graph from values on purpose: reachability cannot change with the text being typed, and the validation hook runs in every reference-aware sub-block editor at once, so subscribing it to live sub-block values would re-render all of them on every keystroke. A test pins that split. `replaceCustomBlockInputs` now seeds from the target block when it is ALREADY the mapped type, layering the configured values on top. That keeps an input the modal cannot offer a control for, and leaves a field the user simply did not touch alone; a field they explicitly emptied stores `''`, which is an override and still wins. Under a DIFFERENT current type nothing is carried over — those values are keyed by another block's field ids, which is the orphaning this function exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/workspaces/[id]/fork/diff/route.ts | 2 +- .../env-var-dropdown/env-var-dropdown.tsx | 2 +- .../components/tag-dropdown/tag-dropdown.tsx | 80 ++------ ...use-accessible-reference-prefixes.test.tsx | 150 +++++++++++++++ .../use-accessible-reference-prefixes.ts | 30 +-- .../hooks/workflow-reference-scope.tsx | 175 ++++++++++++++++++ .../fork-sync/custom-block-input-control.ts | 8 +- .../fork-sync/custom-block-input-field.tsx | 69 +++++++ .../components/fork-sync/fork-sync-view.tsx | 20 +- .../components/fork-sync/reference-input.tsx | 156 ++++++++++++++++ .../lib/copy/copy-workflows.ts | 40 ++-- .../lib/remap/remap-block-type.test.ts | 78 ++++++++ .../lib/remap/remap-references.ts | 23 ++- 13 files changed, 731 insertions(+), 102 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope.tsx create mode 100644 apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-field.tsx create mode 100644 apps/sim/ee/workspace-forking/components/fork-sync/reference-input.tsx diff --git a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts index 87816c36b97..4425c1a4675 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts @@ -154,7 +154,7 @@ export const GET = withRouteHandler( forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey) ) ?? readTargetDraftDependentValue( - targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId), + targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId)?.subBlocks, sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId), field.subBlockKey ), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx index b20ba4efc87..1d2139df30f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx @@ -39,7 +39,7 @@ interface EnvVarDropdownProps { /** Maximum height for the dropdown */ maxHeight?: string /** Reference to the input element for caret positioning */ - inputRef?: React.RefObject + inputRef?: React.RefObject } /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index bce4c0b2ede..cec7d6e9c2b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -11,9 +11,6 @@ import { PopoverSection, usePopoverContext, } from '@sim/emcn' -import { isEqual } from 'es-toolkit' -import { useShallow } from 'zustand/react/shallow' -import { useStoreWithEqualityFn } from 'zustand/traditional' import { getEffectiveBlockOutputType, getOutputPathsFromSchema, @@ -28,19 +25,14 @@ import type { NestedTagChild, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/types' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' +import { useWorkflowReferenceScope } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope' import { getBlock } from '@/blocks' import { BlockTile } from '@/blocks/block-tile' import type { BlockConfig } from '@/blocks/types' import { normalizeName } from '@/executor/constants' -import { useVariablesStore } from '@/stores/variables/store' import type { Variable } from '@/stores/variables/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { EMPTY_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subblock/store' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState } from '@/stores/workflows/workflow/types' -const EMPTY_VARIABLES: Variable[] = [] - /** * Context for sharing nested navigation state between components. * This enables unlimited nesting depth with a single back button. @@ -89,7 +81,7 @@ interface TagDropdownProps { /** Custom styles for positioning */ style?: React.CSSProperties /** Reference to the input element for caret positioning */ - inputRef?: React.RefObject + inputRef?: React.RefObject } interface TagComputationResult { @@ -197,16 +189,13 @@ const ensureRootTag = (tags: string[], rootTag: string): string[] => { const getOutputTypeForPath = ( block: BlockState, blockConfig: BlockConfig | null, - blockId: string, outputPath: string, - mergedSubBlocksOverride?: Record + subBlocks: Record ): string => { if (block?.type === 'variables') { return 'any' } - const subBlocks = - mergedSubBlocksOverride ?? useWorkflowStore.getState().blocks[blockId]?.subBlocks const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false const triggerMode = Boolean(block?.triggerMode && isTriggerCapable) @@ -472,13 +461,7 @@ const FolderContentsInner: React.FC = ({ const blockConfig = getBlock(block.type) const mergedSubBlocks = getMergedSubBlocks(group.blockId) - childType = getOutputTypeForPath( - block, - blockConfig || null, - group.blockId, - outputPath, - mergedSubBlocks - ) + childType = getOutputTypeForPath(block, blockConfig || null, outputPath, mergedSubBlocks) } return ( @@ -667,13 +650,7 @@ const NestedTagRenderer: React.FC = ({ const blockConfig = getBlock(block.type) const mergedSubBlocks = getMergedSubBlocks(group.blockId) - tagDescription = getOutputTypeForPath( - block, - blockConfig || null, - group.blockId, - outputPath, - mergedSubBlocks - ) + tagDescription = getOutputTypeForPath(block, blockConfig || null, outputPath, mergedSubBlocks) } } @@ -950,16 +927,17 @@ export const TagDropdown: React.FC = ({ inputValueRef.current = inputValue cursorPositionRef.current = cursorPosition - const { blocks, edges, loops, parallels } = useWorkflowStore( - useShallow((state) => ({ - blocks: state.blocks, - edges: state.edges, - loops: state.loops || {}, - parallels: state.parallels || {}, - })) - ) - - const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + // The workflow being referenced — the editor's own on the canvas, a supplied one on a + // surface configuring a block that lives in another workflow (see `WorkflowReferenceScope`). + const { + blocks, + edges, + loops, + parallels, + workflowId, + subBlockValues: workflowSubBlockValues, + variables: workflowVariables, + } = useWorkflowReferenceScope() const rawAccessiblePrefixes = useAccessibleReferencePrefixes(blockId) const combinedAccessiblePrefixes = useMemo(() => { @@ -967,10 +945,6 @@ export const TagDropdown: React.FC = ({ return new Set(rawAccessiblePrefixes) }, [rawAccessiblePrefixes]) - const workflowSubBlockValues = useSubBlockStore( - (state) => (workflowId ? state.workflowValues[workflowId] : undefined) ?? EMPTY_SUBBLOCK_VALUES - ) - const getMergedSubBlocks = useCallback( (targetBlockId: string): Record => { const base = blocks[targetBlockId]?.subBlocks || {} @@ -984,18 +958,6 @@ export const TagDropdown: React.FC = ({ [blocks, workflowSubBlockValues] ) - const workflowVariables = useStoreWithEqualityFn( - useVariablesStore, - useCallback( - (state) => - workflowId - ? Object.values(state.variables).filter((variable) => variable.workflowId === workflowId) - : EMPTY_VARIABLES, - [workflowId] - ), - isEqual - ) - const searchTerm = useMemo( () => getTagSearchTerm(inputValue, cursorPosition), [inputValue, cursorPosition] @@ -1479,17 +1441,11 @@ export const TagDropdown: React.FC = ({ const parts = tag.split('.') if (parts.length >= 3 && blockGroup) { const arrayFieldName = parts[1] - const block = useWorkflowStore.getState().blocks[blockGroup.blockId] + const block = blocks[blockGroup.blockId] const blockConfig = block ? (getBlock(block.type) ?? null) : null const mergedSubBlocks = getMergedSubBlocks(blockGroup.blockId) - const fieldType = getOutputTypeForPath( - block, - blockConfig, - blockGroup.blockId, - arrayFieldName, - mergedSubBlocks - ) + const fieldType = getOutputTypeForPath(block, blockConfig, arrayFieldName, mergedSubBlocks) if (fieldType === 'file' || fieldType === 'file[]' || fieldType === 'array') { const blockName = parts[0] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.test.tsx new file mode 100644 index 00000000000..f4d8fef2656 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.test.tsx @@ -0,0 +1,150 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { beforeEach, describe, expect, it } from 'vitest' +import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' +import { + buildWorkflowReferenceScope, + type WorkflowReferenceScope, + WorkflowReferenceScopeProvider, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope' +import { normalizeName } from '@/executor/constants' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import type { BlockState } from '@/stores/workflows/workflow/types' + +const WORKFLOW_ID = 'wf-1' + +function block(id: string, name: string): BlockState { + return { id, name, type: 'agent', subBlocks: {} } as unknown as BlockState +} + +/** A → B → C, so only A is upstream of B and only A/B are upstream of C. */ +const GRAPH = { + blocks: { a: block('a', 'Alpha'), b: block('b', 'Bravo'), c: block('c', 'Charlie') }, + edges: [ + { id: 'e1', source: 'a', target: 'b' }, + { id: 'e2', source: 'b', target: 'c' }, + ], +} + +interface Harness { + result: () => Set | undefined + renderCount: () => number + unmount: () => void +} + +function renderPrefixes(blockId: string | undefined, scope?: WorkflowReferenceScope): Harness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root: Root = createRoot(container) + let latest: Set | undefined + let renders = 0 + + function Probe() { + renders += 1 + latest = useAccessibleReferencePrefixes(blockId) + return null + } + + act(() => { + root.render( + scope ? ( + + + + ) : ( + + ) + ) + }) + + return { + result: () => latest, + renderCount: () => renders, + unmount: () => act(() => root.unmount()), + } +} + +describe('useAccessibleReferencePrefixes', () => { + beforeEach(() => { + useWorkflowStore.setState({ ...GRAPH, loops: {}, parallels: {} }) + useWorkflowRegistry.setState({ activeWorkflowId: WORKFLOW_ID }) + useSubBlockStore.setState({ workflowValues: { [WORKFLOW_ID]: {} } }) + }) + + it('offers only the referencing block and its ancestors on the canvas', () => { + const harness = renderPrefixes('c') + const prefixes = harness.result() + expect(prefixes?.has(normalizeName('Alpha'))).toBe(true) + expect(prefixes?.has(normalizeName('Bravo'))).toBe(true) + expect(prefixes?.has(normalizeName('Charlie'))).toBe(true) + harness.unmount() + }) + + it('excludes a block that is downstream of the referencing one', () => { + const harness = renderPrefixes('a') + expect(harness.result()?.has(normalizeName('Bravo'))).toBe(false) + harness.unmount() + }) + + it('does not re-render when a sub-block VALUE changes', () => { + // Reachability cannot change with the text being typed, and this hook runs in every + // reference-aware sub-block editor at once. Subscribing it to the sub-block store would + // re-render all of them on every keystroke anywhere in the workflow. + const harness = renderPrefixes('c') + const before = harness.renderCount() + act(() => { + useSubBlockStore.setState({ workflowValues: { [WORKFLOW_ID]: { a: { prompt: 'typing' } } } }) + }) + expect(harness.renderCount()).toBe(before) + harness.unmount() + }) + + it('still re-renders when the graph itself changes', () => { + const harness = renderPrefixes('c') + const before = harness.renderCount() + act(() => { + useWorkflowStore.setState({ + blocks: { ...GRAPH.blocks, a: block('a', 'Renamed') }, + }) + }) + expect(harness.renderCount()).toBeGreaterThan(before) + expect(harness.result()?.has(normalizeName('Renamed'))).toBe(true) + harness.unmount() + }) + + it('resolves against a supplied graph instead of the editor’s', () => { + const scope = buildWorkflowReferenceScope({ + workflowId: 'other-wf', + blocks: { x: block('x', 'Extract'), y: block('y', 'Load') }, + edges: [{ id: 'e', source: 'x', target: 'y' }], + referencingBlockId: 'y', + }) + const harness = renderPrefixes('y', scope) + const prefixes = harness.result() + expect(prefixes?.has(normalizeName('Extract'))).toBe(true) + // The editor's own workflow must not leak in. + expect(prefixes?.has(normalizeName('Alpha'))).toBe(false) + harness.unmount() + }) + + it('offers every block when the referencing one is absent from the supplied graph', () => { + // A block this sync is about to ADD has no position in the target workflow yet; the + // ancestor walk would return nothing and suggest nothing at all. + const scope = buildWorkflowReferenceScope({ + workflowId: 'other-wf', + blocks: { x: block('x', 'Extract'), y: block('y', 'Load') }, + edges: [{ id: 'e', source: 'x', target: 'y' }], + referencingBlockId: 'not-in-this-workflow', + }) + const harness = renderPrefixes('not-in-this-workflow', scope) + const prefixes = harness.result() + expect(prefixes?.has(normalizeName('Extract'))).toBe(true) + expect(prefixes?.has(normalizeName('Load'))).toBe(true) + harness.unmount() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts index 9e3830f9c20..e8f0b68a7f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes.ts @@ -1,29 +1,31 @@ import { useMemo } from 'react' -import { useShallow } from 'zustand/react/shallow' import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' import { SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/sanitization/references' +import { useWorkflowReferenceGraph } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope' import { normalizeName } from '@/executor/constants' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { Loop, Parallel } from '@/stores/workflows/workflow/types' export function useAccessibleReferencePrefixes(blockId?: string | null): Set | undefined { - const { blocks, edges, loops, parallels } = useWorkflowStore( - useShallow((state) => ({ - blocks: state.blocks, - edges: state.edges, - loops: state.loops || {}, - parallels: state.parallels || {}, - })) - ) + // The GRAPH only — this runs on every keystroke in every reference-aware sub-block editor, + // and reachability cannot change with the values being typed. + const { blocks, edges, loops, parallels, unrestricted } = useWorkflowReferenceGraph() return useMemo(() => { if (!blockId) { return undefined } - const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target })) - const ancestorIds = BlockPathCalculator.findAllPathNodes(graphEdges, blockId) - const accessibleIds = new Set(ancestorIds) + const accessibleIds = new Set() + if (unrestricted) { + // The referencing block is not in this graph, so there is no path to walk. Every block + // is offered instead of none — see `WorkflowReferenceScope.unrestricted`. + Object.keys(blocks).forEach((id) => accessibleIds.add(id)) + } else { + const graphEdges = edges.map((edge) => ({ source: edge.source, target: edge.target })) + BlockPathCalculator.findAllPathNodes(graphEdges, blockId).forEach((id) => + accessibleIds.add(id) + ) + } accessibleIds.add(blockId) Object.values(loops as Record).forEach((loop) => { @@ -46,5 +48,5 @@ export function useAccessibleReferencePrefixes(blockId?: string | null): Set prefixes.add(prefix)) return prefixes - }, [blockId, blocks, edges, loops, parallels]) + }, [blockId, blocks, edges, loops, parallels, unrestricted]) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope.tsx new file mode 100644 index 00000000000..109eacb7f36 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope.tsx @@ -0,0 +1,175 @@ +'use client' + +import { createContext, useCallback, useContext, useMemo } from 'react' +import { isEqual } from 'es-toolkit' +import type { Edge } from 'reactflow' +import { useShallow } from 'zustand/react/shallow' +import { useStoreWithEqualityFn } from 'zustand/traditional' +import { useVariablesStore } from '@/stores/variables/store' +import type { Variable } from '@/stores/variables/types' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { EMPTY_SUBBLOCK_VALUES, useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' + +const EMPTY_VARIABLES: Variable[] = [] +const EMPTY_BLOCKS: Record = {} +const EMPTY_EDGES: Edge[] = [] +const EMPTY_LOOPS: Record = {} +const EMPTY_PARALLELS: Record = {} + +/** + * The workflow a `` autocomplete resolves against. + * + * On the canvas this is the workflow open in the editor, read from the live stores — which is + * why every reference surface used to read those stores directly. Off-canvas there IS no open + * workflow, so a surface that configures a block belonging to some OTHER workflow (the fork + * sync modal configuring a custom block in a target workspace's workflow) supplies the graph + * itself. Both paths then produce identical, position-aware suggestions. + */ +/** + * The graph half of a scope: everything a block's REACHABILITY depends on. + * + * Split out from the rest because it is what the reference-validation path reads, on every + * keystroke, from several sub-block editors at once. Widening that read to the whole scope + * would subscribe those editors to live sub-block values — which change on every keystroke + * anywhere in the workflow — and re-render them for edits that cannot affect reachability. + */ +export interface WorkflowReferenceGraph { + blocks: Record + edges: Edge[] + loops: Record + parallels: Record + /** + * Offer every block in the graph rather than only the referencing block's ancestors. + * + * The ancestor walk needs the referencing block to BE in the graph. A surface configuring a + * block the graph does not contain yet — the fork sync modal reaching a block this sync is + * about to add — would otherwise resolve to an empty ancestor set and suggest nothing at all. + * Showing the whole workflow is the honest degradation: over-offering a reference the user + * can see is wrong beats offering none. + */ + unrestricted?: boolean +} + +export interface WorkflowReferenceScope extends WorkflowReferenceGraph { + /** Scopes workflow-level variables; `null` when no workflow is in scope. */ + workflowId: string | null + /** + * Unsaved sub-block edits layered over `blocks`, so a reference reflects what the user is + * typing rather than what was last persisted. Empty off-canvas: nothing is being edited there. + */ + subBlockValues: Record> + variables: Variable[] +} + +const WorkflowReferenceScopeContext = createContext(null) + +interface WorkflowReferenceScopeProviderProps { + scope: WorkflowReferenceScope + children: React.ReactNode +} + +/** + * Point every reference autocomplete inside at a workflow other than the one open in the + * editor. Canvas surfaces mount no provider and keep reading the live stores. + */ +export function WorkflowReferenceScopeProvider({ + scope, + children, +}: WorkflowReferenceScopeProviderProps) { + return ( + + {children} + + ) +} + +/** + * The graph a reference resolves against: a supplied scope's when one is provided, the live + * editor's otherwise. + * + * The live read runs either way — hooks cannot be called conditionally — which costs nothing + * off-canvas, where the workflow store holds no workflow and never updates. It subscribes to + * exactly the workflow-store slice the canvas always did, and nothing else. + */ +export function useWorkflowReferenceGraph(): WorkflowReferenceGraph { + const provided = useContext(WorkflowReferenceScopeContext) + const live = useWorkflowStore( + useShallow((state) => ({ + blocks: state.blocks, + edges: state.edges, + loops: state.loops || EMPTY_LOOPS, + parallels: state.parallels || EMPTY_PARALLELS, + })) + ) + return provided ?? live +} + +/** The editor's own workflow-scoped values, straight from the live stores. */ +function useLiveWorkflowValues(): Omit { + const workflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + const subBlockValues = useSubBlockStore( + (state) => (workflowId ? state.workflowValues[workflowId] : undefined) ?? EMPTY_SUBBLOCK_VALUES + ) + const variables = useStoreWithEqualityFn( + useVariablesStore, + useCallback( + (state) => + workflowId + ? Object.values(state.variables).filter((variable) => variable.workflowId === workflowId) + : EMPTY_VARIABLES, + [workflowId] + ), + isEqual + ) + return { workflowId, subBlockValues, variables } +} + +/** + * The full scope, for surfaces that render the tag list itself (block outputs need live + * sub-block values to type their fields; workflow variables are their own tag group). + */ +export function useWorkflowReferenceScope(): WorkflowReferenceScope { + const provided = useContext(WorkflowReferenceScopeContext) + const graph = useWorkflowReferenceGraph() + const live = useLiveWorkflowValues() + const values = provided ?? live + return useMemo( + () => ({ + ...graph, + workflowId: values.workflowId, + subBlockValues: values.subBlockValues, + variables: values.variables, + }), + [graph, values.workflowId, values.subBlockValues, values.variables] + ) +} + +/** + * A scope for a workflow loaded outside the editor. `unrestricted` is derived rather than + * asked for: it is exactly the case where the referencing block is absent from the graph, and + * making the caller work that out invites getting it wrong. + */ +export function buildWorkflowReferenceScope(params: { + workflowId: string | null + blocks: Record | undefined + edges: Edge[] | undefined + loops?: Record + parallels?: Record + variables?: Variable[] + /** The block whose position filters the suggestions. */ + referencingBlockId?: string +}): WorkflowReferenceScope { + const blocks = params.blocks ?? EMPTY_BLOCKS + return { + workflowId: params.workflowId, + blocks, + edges: params.edges ?? EMPTY_EDGES, + loops: params.loops ?? EMPTY_LOOPS, + parallels: params.parallels ?? EMPTY_PARALLELS, + subBlockValues: EMPTY_SUBBLOCK_VALUES, + variables: params.variables ?? EMPTY_VARIABLES, + unrestricted: !params.referencingBlockId || !blocks[params.referencingBlockId], + } +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts index 1bce31c08c4..600da74802b 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts @@ -68,5 +68,9 @@ export function customBlockBooleanOptions(required: boolean) { return required ? BOOLEAN_VALUE_OPTIONS : BOOLEAN_OPTIONAL_OPTIONS } -/** Shown in place of a control for a field the sync modal cannot configure. */ -export const CUSTOM_BLOCK_UNSUPPORTED_HINT = 'Set in the workflow — files cannot be configured here' +/** + * Shown in place of a control for a field the sync modal cannot configure. States what the + * sync does rather than what the modal can't: the target's uploaded files are carried across + * untouched (see `replaceCustomBlockInputs`), so the field is safe to leave alone. + */ +export const CUSTOM_BLOCK_UNSUPPORTED_HINT = 'Uploaded in the workflow — kept as configured there' diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-field.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-field.tsx new file mode 100644 index 00000000000..f34ac6b832e --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-field.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useMemo } from 'react' +import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork' +import { + buildWorkflowReferenceScope, + WorkflowReferenceScopeProvider, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/workflow-reference-scope' +import { ReferenceInput } from '@/ee/workspace-forking/components/fork-sync/reference-input' +import { useWorkflowState } from '@/hooks/queries/workflows' + +interface CustomBlockInputFieldProps { + field: ForkDependentReconfig + value: string + onChange: (value: string) => void + /** The workspace being written into — scopes the `{{secret}}` suggestions. */ + targetWorkspaceId: string + /** JSON-valued (`object` / `array`) fields get the multi-line editor. */ + multiline?: boolean +} + +/** + * One text-valued input of a repointed custom block, with the canvas's own `{{secret}}` and + * `` autocompletes pointed at the environment the value is being written into. + * + * The suggestions come from the TARGET side on purpose. A value configured here is applied to + * the target workspace's copy of the workflow, so it has to resolve there: a secret the user + * can see in the workspace they are looking at may not exist in the other one, and a block + * reference means whatever the hosting workflow calls that block. + * + * The hosting workflow is fetched by id rather than read from the editor stores, which hold + * the workflow open on the canvas — on this page, none. + */ +export function CustomBlockInputField({ + field, + value, + onChange, + targetWorkspaceId, + multiline, +}: CustomBlockInputFieldProps) { + const { data: hostWorkflow } = useWorkflowState(field.targetWorkflowId) + + const scope = useMemo( + () => + buildWorkflowReferenceScope({ + workflowId: field.targetWorkflowId, + blocks: hostWorkflow?.blocks, + edges: hostWorkflow?.edges, + loops: hostWorkflow?.loops, + parallels: hostWorkflow?.parallels, + referencingBlockId: field.targetBlockId, + }), + [field.targetWorkflowId, field.targetBlockId, hostWorkflow] + ) + + return ( + + + + ) +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index 14b5adc01fb..876b1dd67b7 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -8,7 +8,6 @@ import { ChipCombobox, ChipInput, ChipSwitch, - ChipTextarea, CollapsibleCard, cn, FieldDivider, @@ -39,6 +38,7 @@ import { customBlockBooleanOptions, customBlockInputControl, } from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control' +import { CustomBlockInputField } from '@/ee/workspace-forking/components/fork-sync/custom-block-input-field' import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector' import { applyDependentRepick, @@ -244,12 +244,12 @@ function DependentSelector({ ) case 'textarea': return ( - setValue(event.target.value)} - rows={3} - placeholder={`Enter ${field.title} as JSON`} + onChange={setValue} + targetWorkspaceId={workspaceId} + multiline /> ) case 'unsupported': @@ -264,11 +264,11 @@ function DependentSelector({ ) default: return ( - setValue(event.target.value)} - placeholder={`Enter ${field.title}`} + onChange={setValue} + targetWorkspaceId={workspaceId} /> ) } diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/reference-input.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/reference-input.tsx new file mode 100644 index 00000000000..e0d5e4c344c --- /dev/null +++ b/apps/sim/ee/workspace-forking/components/fork-sync/reference-input.tsx @@ -0,0 +1,156 @@ +'use client' + +import { useRef, useState } from 'react' +import { ChipInput, ChipTextarea } from '@sim/emcn' +import { + checkEnvVarTrigger, + EnvVarDropdown, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown' +import { + checkTagTrigger, + TagDropdown, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' + +interface ReferenceInputProps { + value: string + onChange: (value: string) => void + placeholder?: string + /** Multi-line editor, for the JSON-valued (`object` / `array`) fields. */ + multiline?: boolean + /** + * Workspace whose secrets `{{` offers. This is the workspace the value is written INTO, so + * the suggestions are the ones that will actually resolve at run time — a secret from the + * workspace the user happens to be looking at would not. + */ + workspaceId: string + /** + * Block the value belongs to. Positions the `<` suggestions within its workflow, which is + * supplied by the enclosing `WorkflowReferenceScopeProvider` rather than the live editor. + */ + blockId: string + 'aria-label'?: string +} + +/** + * A value field that resolves `{{SECRET}}` and `` the same way the canvas does. + * + * Both dropdowns are the canvas components verbatim — the same trigger helpers, the same + * caret anchoring, the same insertion semantics — so a reference authored here reads and + * behaves identically to one authored on the block itself. Only their DATA differs, and that + * is the point: secrets come from the workspace being written into, and block outputs from + * the workflow that will host the block, neither of which is the one on screen. + */ +export function ReferenceInput({ + value, + onChange, + placeholder, + multiline = false, + workspaceId, + blockId, + 'aria-label': ariaLabel, +}: ReferenceInputProps) { + // One ref for both branches: the dropdowns anchor to whichever element is mounted, and only + // ever call `focus`/`setSelectionRange`, which input and textarea share. + const inputRef = useRef(null) + const [cursorPosition, setCursorPosition] = useState(0) + const [showEnvVars, setShowEnvVars] = useState(false) + const [showTags, setShowTags] = useState(false) + const [searchTerm, setSearchTerm] = useState('') + const [activeSourceBlockId, setActiveSourceBlockId] = useState(null) + + const closeDropdowns = () => { + setShowEnvVars(false) + setShowTags(false) + setSearchTerm('') + setActiveSourceBlockId(null) + } + + const handleChange = (event: React.ChangeEvent) => { + const next = event.target.value + const cursor = event.target.selectionStart ?? next.length + onChange(next) + setCursorPosition(cursor) + + const envVar = checkEnvVarTrigger(next, cursor) + setShowEnvVars(envVar.show) + setSearchTerm(envVar.show ? envVar.searchTerm : '') + + const tag = checkTagTrigger(next, cursor) + setShowTags(tag.show) + if (!tag.show) setActiveSourceBlockId(null) + } + + /** + * A dropdown rewrites the whole value, so the caret has to be put back explicitly — the + * field is controlled and would otherwise land at the end, mid-reference. + */ + const applySelection = (next: string, nextCursor: number) => { + onChange(next) + setCursorPosition(nextCursor) + closeDropdowns() + requestAnimationFrame(() => { + const element = inputRef.current + if (!element) return + element.focus() + element.setSelectionRange(nextCursor, nextCursor) + }) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape' && (showEnvVars || showTags)) { + event.preventDefault() + event.stopPropagation() + closeDropdowns() + } + } + + // Deliberately no `onBlur` close: both dropdowns commit on `onMouseDown`, and a blur handler + // races that even with the items' `preventDefault`. They close on select, Escape, or an + // outside press routed through the popover's own dismissal. + const shared = { + value, + onChange: handleChange, + onKeyDown: handleKeyDown, + placeholder, + 'aria-label': ariaLabel, + } + + return ( +
+ {multiline ? ( + } + className='w-full' + rows={3} + /> + ) : ( + } + className='w-full' + /> + )} + + +
+ ) +} diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 6d89d41168f..0ecb17df411 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -262,8 +262,21 @@ export async function loadWorkflowNameRegistry( } /** - * Batched read of the current DRAFT subBlocks for a set of (replace) target - * workflows, keyed `workflowId -> blockId -> subBlocks`. One query for the whole + * One target block as it stands BEFORE this sync overwrites it. + * + * The `type` rides along with the sub-blocks because a custom block's inputs are only + * meaningful under the type that declared them: the same field id on a different block is a + * different workflow's field. {@link replaceCustomBlockInputs} uses the pair to decide whether + * the target's own values may be kept. + */ +export interface ForkTargetDraftBlock { + type: string + subBlocks: SubBlockRecord +} + +/** + * Batched read of the current DRAFT blocks for a set of (replace) target + * workflows, keyed `workflowId -> blockId -> block`. One query for the whole * promote so the locked apply phase doesn't do N per-workflow loads; called * pre-write so it reflects the target state the user configured before this sync * overwrites it. Promote uses it to detect required dependents the sync left empty @@ -272,13 +285,14 @@ export async function loadWorkflowNameRegistry( export async function loadTargetDraftSubBlocks( executor: DbOrTx, workflowIds: string[] -): Promise>> { - const byWorkflow = new Map>() +): Promise>> { + const byWorkflow = new Map>() if (workflowIds.length === 0) return byWorkflow const rows = await executor .select({ workflowId: workflowBlocks.workflowId, blockId: workflowBlocks.id, + blockType: workflowBlocks.type, subBlocks: workflowBlocks.subBlocks, }) .from(workflowBlocks) @@ -286,10 +300,13 @@ export async function loadTargetDraftSubBlocks( for (const row of rows) { let blocks = byWorkflow.get(row.workflowId) if (!blocks) { - blocks = new Map() + blocks = new Map() byWorkflow.set(row.workflowId, blocks) } - blocks.set(row.blockId, (row.subBlocks ?? {}) as SubBlockRecord) + blocks.set(row.blockId, { + type: row.blockType, + subBlocks: (row.subBlocks ?? {}) as SubBlockRecord, + }) } return byWorkflow } @@ -373,12 +390,13 @@ export interface CopyWorkflowStateParams { */ transformBlockType?: (blockType: string, block: { id: string; name: string }) => string /** - * The target workflow's current draft subBlocks (block id -> subBlocks), for + * The target workflow's current draft blocks (block id -> type + subBlocks), for * `replace` mode only. When present, required dependents that the sync left empty * (the parent change cleared and the stored mapping didn't fill) are reported in - * {@link CopyWorkflowResult.needsConfiguration}. + * {@link CopyWorkflowResult.needsConfiguration}, and a custom block keeps the target's own + * values for inputs the sync modal cannot configure (see {@link replaceCustomBlockInputs}). */ - targetCurrentBlocks?: Map + targetCurrentBlocks?: Map /** * Per-block (block id -> subBlock key -> value) stored dependent values applied last, * after the reference transform cleared the source's, so the stored mapping is the sole @@ -545,7 +563,7 @@ export async function copyWorkflowStateIntoTarget( block.type, newBlockId, block.name, - targetCurrent, + targetCurrent.subBlocks, subBlocks, activeCanonicalModes ) @@ -562,7 +580,7 @@ export async function copyWorkflowStateIntoTarget( // wholesale because ALL of a custom block's inputs are reconfigurable, not a `dependsOn` // subset. `applyDependentOverrides` above is a no-op for them: it allowlists on // `dependsOn` + `selectorKey`, which no custom-block input has. - subBlocks = replaceCustomBlockInputs(subBlocks, blockOverrides, nextBlockType) + subBlocks = replaceCustomBlockInputs(subBlocks, blockOverrides, nextBlockType, targetCurrent) } newBlocks[newBlockId] = { diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-block-type.test.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-block-type.test.ts index 7a71052e665..293aa40cf48 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-block-type.test.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-block-type.test.ts @@ -3,8 +3,10 @@ */ import { describe, expect, it } from 'vitest' import { + customBlockInputStorageKey, type ForkReferenceResolver, remapForkBlockType, + replaceCustomBlockInputs, scanWorkflowReferences, } from '@/ee/workspace-forking/lib/remap/remap-references' @@ -117,3 +119,79 @@ describe('scanWorkflowReferences with custom blocks', () => { expect(scan.unmapped).toHaveLength(1) }) }) + +describe('replaceCustomBlockInputs target carry-over', () => { + /** The source block's inputs, keyed by the SOURCE block's field ids. */ + const sourceSubBlocks = { + workflowId: { value: 'wf-prod' }, + invoice: { value: 'from prod' }, + } + const key = (fieldType: string, fieldId: string) => + customBlockInputStorageKey(UAT_BLOCK, fieldType, fieldId) + + it('keeps an input the modal cannot configure when the target is already this block', () => { + // A `file[]` input is an upload on the canvas, so it never has a stored override. Before + // the carry-over every sync rebuilt the block without it and silently dropped the files. + const result = replaceCustomBlockInputs( + sourceSubBlocks, + new Map([[key('string', 'vendor'), 'Acme']]), + UAT_BLOCK, + { + type: UAT_BLOCK, + subBlocks: { + attachments: { value: ['uat-file-1'] }, + vendor: { value: 'stale' }, + }, + } + ) + expect(result.attachments).toEqual({ value: ['uat-file-1'] }) + // A configured value still wins over the target's own. + expect(result.vendor).toEqual({ value: 'Acme' }) + }) + + it('carries nothing over when the target currently holds a DIFFERENT custom block', () => { + // The target's field ids describe another workflow's Start fields; keeping them is exactly + // the orphaning this function exists to prevent. + const result = replaceCustomBlockInputs(sourceSubBlocks, undefined, UAT_BLOCK, { + type: 'custom_block_someotherxyz', + subBlocks: { attachments: { value: ['other-file'] } }, + }) + expect(result.attachments).toBeUndefined() + }) + + it('carries nothing over for a non-custom target block', () => { + const result = replaceCustomBlockInputs(sourceSubBlocks, undefined, UAT_BLOCK, { + type: 'agent', + subBlocks: { systemPrompt: { value: 'hello' } }, + }) + expect(result.systemPrompt).toBeUndefined() + }) + + it('lets an explicitly emptied field clear the target value', () => { + // `''` is a stored override, not an absent one — so clearing a field in the modal is a + // real edit rather than a silent no-op. + const result = replaceCustomBlockInputs( + sourceSubBlocks, + new Map([[key('string', 'vendor'), '']]), + UAT_BLOCK, + { type: UAT_BLOCK, subBlocks: { vendor: { value: 'previous' } } } + ) + expect(result.vendor).toEqual({ value: '' }) + }) + + it('takes reserved wiring from the source, never the target', () => { + // `workflowId`/`inputMapping` are recomputed by the serializer; the target's copy is stale + // the moment the mapping changes. + const result = replaceCustomBlockInputs(sourceSubBlocks, undefined, UAT_BLOCK, { + type: UAT_BLOCK, + subBlocks: { workflowId: { value: 'wf-stale-uat' } }, + }) + expect(result.workflowId).toEqual({ value: 'wf-prod' }) + }) + + it('still drops the source block-keyed inputs with no target to carry over', () => { + const result = replaceCustomBlockInputs(sourceSubBlocks, undefined, UAT_BLOCK) + expect(result.invoice).toBeUndefined() + expect(result.workflowId).toEqual({ value: 'wf-prod' }) + }) +}) diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts index a8a7df7f3f4..6967a88a02f 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts @@ -299,16 +299,37 @@ export function parseCustomBlockInputStorageKey(key: string): ParsedCustomBlockI * skipped, so re-pointing a block twice never carries the first target's values into the * second. Reserved wiring (`workflowId`/`inputMapping`) is preserved untouched: those are * computed value-fns the serializer recomputes and never carries forward. + * + * `targetCurrent` is the block the sync is about to overwrite. When it is ALREADY the mapped + * type — the normal state of every sync after the one that set the mapping — its own values + * seed the result and the configured ones are layered on top. That is what stops a re-sync + * wiping an input the modal cannot offer a control for: a `file[]` field is an upload on the + * canvas, so it is only ever set there, and rebuilding the block from the stored overrides + * alone would blank it every single time. It also means a field the user simply left alone in + * the modal keeps the target's value rather than being cleared; a field they explicitly + * emptied stores `''`, which is an override and still wins. + * + * The type equality check is the whole safety property. Under a DIFFERENT current type the + * target's values are keyed by another block's field ids, which is exactly the orphaning this + * function exists to prevent — so nothing is carried over. */ export function replaceCustomBlockInputs( subBlocks: SubBlockRecord, values: ReadonlyMap | undefined, - targetType: string + targetType: string, + targetCurrent?: { type: string; subBlocks: SubBlockRecord } ): SubBlockRecord { const next: SubBlockRecord = {} for (const [key, subBlock] of Object.entries(subBlocks)) { if (RESERVED_PARAMS.has(key)) next[key] = subBlock } + if (targetCurrent?.type === targetType) { + for (const [key, subBlock] of Object.entries(targetCurrent.subBlocks)) { + // Reserved wiring is taken from the SOURCE block above: it is recomputed by the + // serializer, and the target's copy is stale the moment the mapping changes. + if (!RESERVED_PARAMS.has(key)) next[key] = subBlock + } + } for (const [key, value] of values ?? []) { const parsed = parseCustomBlockInputStorageKey(key) if (!parsed || parsed.targetType !== targetType) continue From 32015e37a9042011820138df50c4ded8f1dfbd91 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 18:45:42 -0700 Subject: [PATCH 03/14] fix(workspace-forking): stop a required file input deadlocking Sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both PR bots flagged this and they were right. A repointed custom block's `file[]` input renders as a disabled control — it is an upload on the canvas, and there is nothing to type here — but the Sync gate still demanded a non-empty value for every REQUIRED dependent. So a custom block with a required file input turned Sync off permanently, while the field's own hint told the user to go set it in a workflow they could only reach BY syncing. `isForkSyncConfigurableField` is the one predicate for "can the modal put a value in this field", used by the gate and by the per-kind status badge so the two cannot disagree. Skipping the gate is only safe because the sync no longer clears the field: the target keeps what it has, and a genuinely missing value is still caught by the block's own required-field validation at run/deploy time — the same fallback every other unconfigured required field already relies on. Also gives the disabled control an `aria-label` (the row's visible label is a sibling, not associated), closing the second review note. Co-Authored-By: Claude Opus 5 (1M context) --- .../custom-block-input-control.test.ts | 26 +++++++++++++++++++ .../fork-sync/custom-block-input-control.ts | 21 +++++++++++++++ .../components/fork-sync/fork-sync-view.tsx | 1 + .../components/fork-sync/use-fork-sync.ts | 7 +++++ 4 files changed, 55 insertions(+) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts index 437b492fdb4..07af1577779 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts @@ -9,6 +9,7 @@ import { CUSTOM_BLOCK_BOOLEAN_UNSET, customBlockBooleanOptions, customBlockInputControl, + isForkSyncConfigurableField, } from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control' describe('customBlockInputControl', () => { @@ -50,6 +51,31 @@ describe('customBlockInputControl', () => { }) }) +describe('isForkSyncConfigurableField', () => { + it('excludes a custom block’s file input from the Sync gate', () => { + // The modal renders it disabled, so a REQUIRED one could never be satisfied: Sync would + // stay off forever while the hint told the user to set it in a workflow they can only + // reach by syncing. The sync no longer clears the field, so skipping the gate is safe. + expect(isForkSyncConfigurableField({ parentKind: 'custom-block', fieldType: 'file[]' })).toBe( + false + ) + }) + + it('still gates every custom-block input that HAS a control', () => { + for (const fieldType of ['string', 'number', 'boolean', 'object', 'array']) { + expect(isForkSyncConfigurableField({ parentKind: 'custom-block', fieldType })).toBe(true) + } + }) + + it('leaves every other parent kind gated', () => { + // Only custom-block inputs classify their own control here; a selector-backed dependent + // is always configurable. + for (const parentKind of ['credential', 'knowledge-base', 'table'] as const) { + expect(isForkSyncConfigurableField({ parentKind, fieldType: 'file[]' })).toBe(true) + } + }) +}) + describe('customBlockBooleanOptions', () => { it('lets an OPTIONAL flag return to the workflow default', () => { // Without this a single click permanently pins the flag: a two-segment switch has no diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts index 600da74802b..a94764c01d0 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts @@ -1,3 +1,4 @@ +import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork' import { subBlockTypeForField } from '@/blocks/custom/build-config' /** @@ -74,3 +75,23 @@ export function customBlockBooleanOptions(required: boolean) { * untouched (see `replaceCustomBlockInputs`), so the field is safe to leave alone. */ export const CUSTOM_BLOCK_UNSUPPORTED_HINT = 'Uploaded in the workflow — kept as configured there' + +/** + * Whether the sync modal can actually put a value in this field. + * + * The Sync gate demands a value for every REQUIRED dependent, which is right for a field the + * user can fill and a deadlock for one they cannot: a required `file[]` renders as a disabled + * control, so the gate could never be satisfied and Sync stayed off forever — with the hint + * telling the user to go set it in a workflow they could not reach. + * + * Excluding it is safe because the sync no longer clears it. The target keeps whatever it has + * (see `replaceCustomBlockInputs`), and a genuinely missing value is still caught by the + * block's own required-field validation at run/deploy time — the same fallback every other + * unconfigured required field relies on. + */ +export function isForkSyncConfigurableField( + field: Pick +): boolean { + if (field.parentKind !== 'custom-block') return true + return customBlockInputControl(field.fieldType) !== 'unsupported' +} diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index 876b1dd67b7..2f9d7843ae6 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -260,6 +260,7 @@ function DependentSelector({ onChange={() => {}} disabled placeholder={CUSTOM_BLOCK_UNSUPPORTED_HINT} + aria-label={field.title} /> ) default: diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts index 28a26706642..35d7b383705 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts @@ -30,6 +30,7 @@ import { forkVisibleCopyables, isForkRequiredComplete, } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation' +import { isForkSyncConfigurableField } from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control' import { type DependentReconfigState, dependentKey, @@ -561,6 +562,9 @@ export function useForkSync(params: { // instead, so it's skipped here. const reconfigComplete = dependentReconfigs.every((field) => { if (!field.required) return true + // A field the modal renders no control for can never satisfy this gate — see + // `isForkSyncConfigurableField`. + if (!isForkSyncConfigurableField(field)) return true const parent = entryForDependent(field) if (!parent) return true const resolution = resolutionFor(parent) @@ -574,6 +578,9 @@ export function useForkSync(params: { const reconfigPendingByKind = new Set() for (const field of dependentReconfigs) { if (!field.required) continue + // Mirrors the Sync gate above: a field it cannot block on must not make the kind's badge + // read "Needs setup" forever either. + if (!isForkSyncConfigurableField(field)) continue const parent = entryForDependent(field) if (!parent) continue const resolution = resolutionFor(parent) From cda0b92666c39ee65aaf5af06277e09d1ef0a576 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 19:05:56 -0700 Subject: [PATCH 04/14] refactor(sub-blocks): make a registered selector the single source for a remote option list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dropdown` and `combobox` could only load a remote list through a per-block `fetchOptions(blockId)`, which resolves its credential by reading the live workflow store. That works on the canvas and nowhere else — which is why the fork sync modal cannot offer those fields, and why every one of those fetchers turned out to be a hand-rolled duplicate of a selector that already exists (`triggers/gmail/poller.ts` calls the very contract `gmail.labels` wraps). Both controls now accept `selectorKey`, resolved through the registry inside `useFetchedOptions`. Deliberately NOT a second code path: the registry is presented through the same two function shapes the props already describe, so the existing lifecycle — request-id guards, dependency-scope reset, label hydration — is reused verbatim, and paginated selectors drain through the same `loadAllSelectorOptions` that search/replace and value resolution already use. `isDynamic` replaces the `fetchOptions &&` test the controls used to decide whether the fetched list or the static `options` array is authoritative; that question outlives the prop it was asking about. No block or trigger changes yet, so nothing moves off `fetchOptions` in this commit: subblock `type`, `multiSelect`, and the stored value shape are all untouched and no existing workflow is affected. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/combobox/combobox.tsx | 8 +- .../components/dropdown/dropdown.tsx | 12 ++- .../sub-block/hooks/use-fetched-options.ts | 89 ++++++++++++++++++- .../editor/components/sub-block/sub-block.tsx | 2 + 4 files changed, 104 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index f8ebccf4356..92226dd6529 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -14,6 +14,7 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' import type { SubBlockConfig } from '@/blocks/types' +import type { SelectorKey } from '@/hooks/selectors/types' import { usePermissionConfig } from '@/hooks/use-permission-config' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -68,6 +69,8 @@ interface ComboBoxProps { placeholder?: string /** Configuration for the sub-block */ config: SubBlockConfig + /** Registered selector supplying the options. The canonical source for a remote list. */ + selectorKey?: SelectorKey /** Async function to fetch options dynamically */ fetchOptions?: (blockId: string) => Promise> /** Async function to fetch a single option's label by ID (for hydration) */ @@ -90,6 +93,7 @@ export const ComboBox = memo(function ComboBox({ disabled, placeholder = 'Type or select an option...', config, + selectorKey, fetchOptions, fetchOptionById, dependsOn, @@ -126,10 +130,12 @@ export const ComboBox = memo(function ComboBox({ fetchError, hydratedOption, missingOptionId, + isDynamic, refetch: refetchOptions, } = useFetchedOptions({ blockId, dependsOnFields, + selectorKey, fetchOptions, fetchOptionById, isPreview: Boolean(isPreview), @@ -194,7 +200,7 @@ export const ComboBox = memo(function ComboBox({ // Merge static and fetched options - fetched options take priority when available const evaluatedOptions = useMemo((): ComboBoxOption[] => { let opts: ComboBoxOption[] = - fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions + isDynamic && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) { opts = opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id)) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index b14dfc42c4e..8d406ebbcfc 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -15,6 +15,7 @@ import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflow import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' +import type { SelectorKey } from '@/hooks/selectors/types' import { useOperationAccess } from '@/hooks/use-operation-access' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -59,6 +60,8 @@ interface DropdownProps { placeholder?: string /** Enable multi-select mode */ multiSelect?: boolean + /** Registered selector supplying the options. The canonical source for a remote list. */ + selectorKey?: SelectorKey /** Async function to fetch options dynamically */ fetchOptions?: (blockId: string) => Promise> /** Async function to fetch a single option's label by ID (for hydration) */ @@ -94,6 +97,7 @@ export const Dropdown = memo(function Dropdown({ disabled, placeholder = 'Select an option...', multiSelect = false, + selectorKey, fetchOptions, fetchOptionById, dependsOn, @@ -145,10 +149,12 @@ export const Dropdown = memo(function Dropdown({ isLoadingOptions, fetchError, hydratedOption, + isDynamic, refetch: refetchOptions, } = useFetchedOptions({ blockId, dependsOnFields, + selectorKey, fetchOptions, fetchOptionById, isPreview: Boolean(isPreview), @@ -175,9 +181,7 @@ export const Dropdown = memo(function Dropdown({ const allOptions = useMemo(() => { let opts: DropdownOption[] = - fetchOptions && normalizedFetchedOptions.length > 0 - ? normalizedFetchedOptions - : evaluatedOptions + isDynamic && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : evaluatedOptions if (hydratedOption) { const alreadyPresent = opts.some((o) => @@ -189,7 +193,7 @@ export const Dropdown = memo(function Dropdown({ } return opts - }, [fetchOptions, normalizedFetchedOptions, evaluatedOptions, hydratedOption]) + }, [isDynamic, normalizedFetchedOptions, evaluatedOptions, hydratedOption]) /** * Operation IDs whose resolved tool is denied by the caller's permission diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts index b70f1eca44b..e2d2fb88ab8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts @@ -2,8 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getErrorMessage } from '@sim/utils/errors' import { isEqual } from 'es-toolkit' import { useStoreWithEqualityFn } from 'zustand/traditional' +import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context' import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks/registry' +import { getSelectorDefinition, loadAllSelectorOptions } from '@/hooks/selectors/registry' +import type { SelectorKey } from '@/hooks/selectors/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -20,6 +23,16 @@ interface UseFetchedOptionsProps { blockId: string /** Sibling subblock ids this list is scoped by; a change refetches. */ dependsOnFields: string[] + /** + * The registered selector supplying this control's options. + * + * This is the ONLY way a sub-block loads a remote list. A selector is parameterized by an + * explicit {@link SelectorContext} built from the block's own values, so the same definition + * serves the canvas, the fork sync modal, and any future surface. The alternative that used + * to live here — a per-block `fetchOptions(blockId)` reading the live store — could only ever + * work on the canvas, and was in every case a duplicate of a selector that already existed. + */ + selectorKey?: SelectorKey fetchOptions?: (blockId: string) => Promise fetchOptionById?: (blockId: string, optionId: string) => Promise isPreview: boolean @@ -35,6 +48,13 @@ interface UseFetchedOptionsProps { export interface UseFetchedOptionsResult { fetchedOptions: FetchedOption[] + /** + * Whether this control loads its options remotely at all. Controls use it to decide whether + * `fetchedOptions` or the static `options` array is authoritative — a question they used to + * answer by testing the `fetchOptions` prop, which stops being true once the source is a + * `selectorKey` instead. + */ + isDynamic: boolean isLoadingOptions: boolean fetchError: string | null hydratedOption: FetchedOption | null @@ -61,8 +81,9 @@ function hasLocalOption(options: readonly LocalOption[], id: string): boolean { export function useFetchedOptions({ blockId, dependsOnFields, - fetchOptions, - fetchOptionById, + selectorKey, + fetchOptions: fetchOptionsProp, + fetchOptionById: fetchOptionByIdProp, isPreview, disabled, valueToHydrate, @@ -94,6 +115,69 @@ export function useFetchedOptions({ isEqual ) + /** + * The block's live sub-block values merged over its persisted ones — the shape + * `buildSelectorContextFromBlock` reads. Resolved at call time rather than memoized so a + * selector always fetches against what the user has actually chosen, not a stale snapshot. + */ + const readSelectorContext = useCallback(() => { + const block = useWorkflowStore.getState().blocks[blockId] + if (!block?.type) return null + const live = activeWorkflowId + ? (useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] ?? {}) + : {} + const merged: Record = { ...(block.subBlocks ?? {}) } + for (const [id, value] of Object.entries(live)) merged[id] = { ...merged[id], value } + return buildSelectorContextFromBlock(block.type, merged, { + workflowId: activeWorkflowId ?? undefined, + workspaceId: workspaceId ?? undefined, + canonicalModes: block.data?.canonicalModes, + }) + }, [blockId, activeWorkflowId, workspaceId]) + + const selectorDefinition = selectorKey ? getSelectorDefinition(selectorKey) : undefined + + /** + * A selector-backed control reuses this hook's whole lifecycle by presenting the registry + * through the same two function shapes the props already describe — so there is one fetch + * path, not a second system running alongside it. + * + * Memoized on `dependencyValues` so a changed parent (a newly picked credential) yields a + * new identity and the scope reset below refetches, exactly as it does for a prop fetcher. + */ + const fetchOptions = useMemo(() => { + if (fetchOptionsProp) return fetchOptionsProp + if (!selectorDefinition) return undefined + const definition = selectorDefinition + return async (): Promise => { + const context = readSelectorContext() + if (!context) return [] + const args = { key: definition.key, context } + // The selector's own readiness gate: an unset credential yields an empty list rather + // than an error, which is how every other selector-backed control already behaves. + if (definition.enabled && !definition.enabled(args)) return [] + // Shared with search/replace and value resolution, so a paginated selector drains the + // same bounded way here as everywhere else instead of silently showing one page. + const options = await loadAllSelectorOptions(definition, args) + return options.map((option) => ({ id: option.id, label: option.label })) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- dependencyValues is the refetch scope + }, [fetchOptionsProp, selectorDefinition, readSelectorContext, dependencyValues]) + + /** Label hydration for a stored id, from the same definition. */ + const fetchOptionById = useMemo(() => { + if (fetchOptionByIdProp) return fetchOptionByIdProp + const definition = selectorDefinition + const fetchById = definition?.fetchById + if (!definition || !fetchById) return undefined + return async (_blockId: string, optionId: string, signal?: AbortSignal) => { + const context = readSelectorContext() + if (!context) return null + const option = await fetchById({ key: definition.key, context, detailId: optionId, signal }) + return option ? { id: option.id, label: option.label } : null + } + }, [fetchOptionByIdProp, selectorDefinition, readSelectorContext]) + const [fetchedOptions, setFetchedOptions] = useState([]) const [isLoadingOptions, setIsLoadingOptions] = useState(false) const [fetchError, setFetchError] = useState(null) @@ -229,6 +313,7 @@ export function useFetchedOptions({ return { fetchedOptions, + isDynamic: Boolean(fetchOptions), isLoadingOptions, fetchError, hydratedOption, 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 51dc896c59c..e2dd0313e45 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 @@ -680,6 +680,7 @@ function SubBlockComponent({ previewValue={previewValue} disabled={isDisabled} multiSelect={config.multiSelect} + selectorKey={config.selectorKey} fetchOptions={config.fetchOptions} fetchOptionById={config.fetchOptionById} dependsOn={config.dependsOn} @@ -715,6 +716,7 @@ function SubBlockComponent({ previewValue={previewValue as any} disabled={isDisabled} config={config} + selectorKey={config.selectorKey} fetchOptions={config.fetchOptions} fetchOptionById={config.fetchOptionById} dependsOn={config.dependsOn} From 749662db67c4de85f5cf5aecb82d3cd7c4d11415 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 19:22:23 -0700 Subject: [PATCH 05/14] refactor(triggers): move every credential-scoped option list onto a registered selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these `fetchOptions` resolved its credential with `readSubBlockValue(blockId, 'triggerCredentials')` — a live-workflow-store read — and then called the very selector contract a registered selector already wraps. They were duplicates that only worked on the canvas. Migrated: webflow sites/collections (x4 triggers), clickup workspaces, gmail labels, outlook folders, and all six hubspot pickers. 425 lines of duplicated fetch logic deleted. The missing piece each one needed was `canonicalParamId: 'oauthCredential'` on its credential subblock: `buildSelectorContextFromBlock` keys the context on a subblock's CANONICAL id, so without it `context.oauthCredential` was never populated and the block had no way to reach its credential except the store — which is what forced the hand-rolled fetcher in the first place. Five new hubspot selectors. `hubspot.pipelineStages` reads the pipelines contract and narrows, because HubSpot returns stages inside the pipeline payload rather than behind an endpoint of their own; sharing the one response is also what keeps a stage list from ever describing a pipeline its sibling picker is not showing. `objectType`/`customObjectTypeId`/`pipelineId` join SelectorContext, and `resolveObjectType` keeps HubSpot's own `contact` default so an untouched dropdown still lists properties for what it visibly shows. Subblock `type`, `multiSelect`, and stored value shapes are unchanged, so existing workflows are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- .../selectors/providers/hubspot/selectors.ts | 148 ++++++++++++++++++ apps/sim/hooks/selectors/registry.ts | 2 + apps/sim/hooks/selectors/types.ts | 15 ++ apps/sim/lib/workflows/subblocks/context.ts | 3 + apps/sim/triggers/clickup/subblocks.ts | 8 +- apps/sim/triggers/gmail/poller.ts | 27 +--- apps/sim/triggers/hubspot/poller.ts | 108 ++----------- apps/sim/triggers/outlook/poller.ts | 26 +-- .../webflow/collection_item_changed.ts | 91 +---------- .../webflow/collection_item_created.ts | 91 +---------- .../webflow/collection_item_deleted.ts | 91 +---------- apps/sim/triggers/webflow/form_submission.ts | 45 +----- 12 files changed, 203 insertions(+), 452 deletions(-) create mode 100644 apps/sim/hooks/selectors/providers/hubspot/selectors.ts diff --git a/apps/sim/hooks/selectors/providers/hubspot/selectors.ts b/apps/sim/hooks/selectors/providers/hubspot/selectors.ts new file mode 100644 index 00000000000..956210cc123 --- /dev/null +++ b/apps/sim/hooks/selectors/providers/hubspot/selectors.ts @@ -0,0 +1,148 @@ +import { requestJson } from '@/lib/api/client/request' +import * as selectorContracts from '@/lib/api/contracts/selectors' +import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' +import type { + SelectorContext, + SelectorDefinition, + SelectorKey, + SelectorQueryArgs, +} from '@/hooks/selectors/types' + +/** + * HubSpot's default CRM object. A picker renders before the user has touched the object-type + * dropdown, and the dropdown itself already displays `contact` — so resolving to nothing there + * would render every dependent picker empty against a control that visibly shows a selection. + */ +const DEFAULT_OBJECT_TYPE = 'contact' + +/** + * The object type a picker is scoped to. + * + * `custom` is an indirection rather than a type: the real id lives in a sibling field, and + * until that is filled in there is no object to query. Returning `null` for that case keeps + * the dependent pickers empty instead of querying HubSpot for an object called "custom". + */ +function resolveObjectType(context: SelectorContext): string | null { + const selected = context.objectType ?? DEFAULT_OBJECT_TYPE + if (selected !== 'custom') return selected + const customId = context.customObjectTypeId?.trim() + return customId ? customId : null +} + +export const hubspotSelectors = { + 'hubspot.properties': { + key: 'hubspot.properties', + contracts: [selectorContracts.hubspotPropertiesSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'hubspot.properties', + context.oauthCredential ?? 'none', + resolveObjectType(context) ?? 'none', + ], + enabled: ({ context }) => + Boolean(context.oauthCredential) && resolveObjectType(context) !== null, + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'hubspot.properties') + const objectType = resolveObjectType(context) + if (!objectType) return [] + const data = await requestJson(selectorContracts.hubspotPropertiesSelectorContract, { + query: { credentialId, objectType }, + signal, + }) + return data.properties.map((property) => ({ id: property.id, label: property.name })) + }, + }, + 'hubspot.lists': { + key: 'hubspot.lists', + contracts: [selectorContracts.hubspotListsSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'hubspot.lists', + context.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'hubspot.lists') + const data = await requestJson(selectorContracts.hubspotListsSelectorContract, { + query: { credentialId }, + signal, + }) + return data.lists.map((list) => ({ id: list.id, label: list.name })) + }, + }, + 'hubspot.pipelines': { + key: 'hubspot.pipelines', + contracts: [selectorContracts.hubspotPipelinesSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'hubspot.pipelines', + context.oauthCredential ?? 'none', + resolveObjectType(context) ?? 'none', + ], + enabled: ({ context }) => + Boolean(context.oauthCredential) && resolveObjectType(context) !== null, + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'hubspot.pipelines') + const objectType = resolveObjectType(context) + if (!objectType) return [] + const data = await requestJson(selectorContracts.hubspotPipelinesSelectorContract, { + query: { credentialId, objectType }, + signal, + }) + return data.pipelines.map((pipeline) => ({ id: pipeline.id, label: pipeline.name })) + }, + }, + /** + * Stages live INSIDE the pipelines payload rather than behind an endpoint of their own, so + * this reads the same contract and narrows to the selected pipeline. Sharing HubSpot's one + * response is also why both selectors stay in step — a stage list can never describe a + * pipeline the sibling picker is not showing. + */ + 'hubspot.pipelineStages': { + key: 'hubspot.pipelineStages', + contracts: [selectorContracts.hubspotPipelinesSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'hubspot.pipelineStages', + context.oauthCredential ?? 'none', + resolveObjectType(context) ?? 'none', + context.pipelineId ?? 'none', + ], + enabled: ({ context }) => + Boolean(context.oauthCredential && context.pipelineId) && resolveObjectType(context) !== null, + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'hubspot.pipelineStages') + const objectType = resolveObjectType(context) + if (!objectType || !context.pipelineId) return [] + const data = await requestJson(selectorContracts.hubspotPipelinesSelectorContract, { + query: { credentialId, objectType }, + signal, + }) + const pipeline = data.pipelines.find((entry) => entry.id === context.pipelineId) + return (pipeline?.stages ?? []).map((stage) => ({ id: stage.id, label: stage.label })) + }, + }, + 'hubspot.owners': { + key: 'hubspot.owners', + contracts: [selectorContracts.hubspotOwnersSelectorContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'hubspot.owners', + context.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + const credentialId = ensureCredential(context, 'hubspot.owners') + const data = await requestJson(selectorContracts.hubspotOwnersSelectorContract, { + query: { credentialId }, + signal, + }) + return data.owners.map((owner) => ({ id: owner.id, label: owner.name })) + }, + }, +} satisfies Partial> diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index ac1c943226e..9d1cc1b686c 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -8,6 +8,7 @@ import { clickupSelectors } from '@/hooks/selectors/providers/clickup/selectors' import { cloudwatchSelectors } from '@/hooks/selectors/providers/cloudwatch/selectors' import { confluenceSelectors } from '@/hooks/selectors/providers/confluence/selectors' import { googleSelectors } from '@/hooks/selectors/providers/google/selectors' +import { hubspotSelectors } from '@/hooks/selectors/providers/hubspot/selectors' import { jiraSelectors } from '@/hooks/selectors/providers/jira/selectors' import { jsmSelectors } from '@/hooks/selectors/providers/jsm/selectors' import { knowledgeSelectors } from '@/hooks/selectors/providers/knowledge/selectors' @@ -43,6 +44,7 @@ export const selectorRegistry = { ...confluenceSelectors, ...jsmSelectors, ...googleSelectors, + ...hubspotSelectors, ...microsoftSelectors, ...notionSelectors, ...pipedriveSelectors, diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index 402f9d6b698..840640c3c95 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -20,6 +20,11 @@ export type SelectorKey = | 'clickup.lists' | 'confluence.spaces' | 'google.tasks.lists' + | 'hubspot.lists' + | 'hubspot.owners' + | 'hubspot.pipelines' + | 'hubspot.pipelineStages' + | 'hubspot.properties' | 'jsm.requestTypes' | 'jsm.serviceDesks' | 'microsoft.planner.plans' @@ -125,6 +130,16 @@ export interface SelectorContext { orgId?: string /** Bitbucket Cloud workspace slug that scopes repository discovery. */ workspaceSlug?: string + /** + * HubSpot CRM object the pickers are scoped to (`contact` | `deal` | … | `custom`). Left + * unset until the user picks one; the selectors apply HubSpot's own `contact` default so an + * untouched dropdown still lists properties for what it visibly shows. + */ + objectType?: string + /** HubSpot custom object type id (e.g. `2-12345`), used when `objectType` is `custom`. */ + customObjectTypeId?: string + /** HubSpot pipeline whose stages a stage picker enumerates. */ + pipelineId?: string } export interface SelectorQueryArgs { diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index f466a68be22..35156ced0a9 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -42,6 +42,9 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ 'database', 'schema', 'workspaceSlug', + 'objectType', + 'customObjectTypeId', + 'pipelineId', ]) /** diff --git a/apps/sim/triggers/clickup/subblocks.ts b/apps/sim/triggers/clickup/subblocks.ts index a4b9d1700c7..a6f3004330b 100644 --- a/apps/sim/triggers/clickup/subblocks.ts +++ b/apps/sim/triggers/clickup/subblocks.ts @@ -40,6 +40,7 @@ export function buildClickUpTriggerSubBlocks(triggerId: string): SubBlockConfig[ id: 'triggerCredentials', title: 'ClickUp Account', type: 'oauth-input', + canonicalParamId: 'oauthCredential', serviceId: 'clickup', requiredScopes: [], mode: 'trigger', @@ -50,17 +51,12 @@ export function buildClickUpTriggerSubBlocks(triggerId: string): SubBlockConfig[ id: 'triggerWorkspaceId', title: 'Workspace', type: 'dropdown', + selectorKey: 'clickup.workspaces', placeholder: 'Select a workspace', description: 'The ClickUp Workspace the webhook is registered in', required: true, - options: [], mode: 'trigger', condition: { field: 'selectedTriggerId', value: triggerId }, - fetchOptions: fetchWorkspaceOptions, - fetchOptionById: async (blockId: string, optionId: string) => { - const workspaces = await fetchWorkspaceOptions(blockId) - return workspaces.find((workspace) => workspace.id === optionId) ?? null - }, }, { id: 'triggerSpaceId', diff --git a/apps/sim/triggers/gmail/poller.ts b/apps/sim/triggers/gmail/poller.ts index 12d0e4b9425..b9d868294b7 100644 --- a/apps/sim/triggers/gmail/poller.ts +++ b/apps/sim/triggers/gmail/poller.ts @@ -1,8 +1,5 @@ import { createLogger } from '@sim/logger' import { GmailIcon } from '@/components/icons' -import { requestJson } from '@/lib/api/client/request' -import { gmailLabelsSelectorContract } from '@/lib/api/contracts/selectors/google' -import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('GmailPollingTrigger') @@ -21,6 +18,7 @@ export const gmailPollingTrigger: TriggerConfig = { id: 'triggerCredentials', title: 'Credentials', type: 'oauth-input', + canonicalParamId: 'oauthCredential', description: 'This trigger requires google email credentials to access your account.', serviceId: 'gmail', requiredScopes: [], @@ -32,32 +30,11 @@ export const gmailPollingTrigger: TriggerConfig = { title: 'Gmail Labels to Monitor', canvasNoun: 'a label', type: 'dropdown', + selectorKey: 'gmail.labels', multiSelect: true, placeholder: 'Select Gmail labels to monitor for new emails', description: 'Choose which Gmail labels to monitor. Leave empty to monitor all emails.', required: false, - options: [], // Will be populated dynamically from user's Gmail labels - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - if (!credentialId) { - // Return a sentinel to prevent infinite retry loops when credential is missing - throw new Error('No Gmail credential selected') - } - try { - const data = await requestJson(gmailLabelsSelectorContract, { - query: { credentialId }, - }) - return data.labels.map((label) => ({ - id: label.id, - label: label.name, - })) - } catch (error) { - logger.error('Error fetching Gmail labels:', error) - throw error - } - }, dependsOn: ['triggerCredentials'], mode: 'trigger', }, diff --git a/apps/sim/triggers/hubspot/poller.ts b/apps/sim/triggers/hubspot/poller.ts index eb560deed1e..da10f2de3e0 100644 --- a/apps/sim/triggers/hubspot/poller.ts +++ b/apps/sim/triggers/hubspot/poller.ts @@ -1,12 +1,7 @@ import { createLogger } from '@sim/logger' import { HubspotIcon } from '@/components/icons' import { requestJson } from '@/lib/api/client/request' -import { - hubspotListsSelectorContract, - hubspotOwnersSelectorContract, - hubspotPipelinesSelectorContract, - hubspotPropertiesSelectorContract, -} from '@/lib/api/contracts/selectors/hubspot' +import { hubspotPropertiesSelectorContract } from '@/lib/api/contracts/selectors/hubspot' import { getScopesForService } from '@/lib/oauth/utils' import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' @@ -54,6 +49,7 @@ export const hubspotPollingTrigger: TriggerConfig = { id: 'triggerCredentials', title: 'HubSpot Account', type: 'oauth-input', + canonicalParamId: 'oauthCredential', description: 'Connect a HubSpot account so Sim can poll your CRM on your behalf.', serviceId: 'hubspot', requiredScopes: getScopesForService('hubspot'), @@ -65,6 +61,7 @@ export const hubspotPollingTrigger: TriggerConfig = { id: 'objectType', title: 'Object Type', type: 'dropdown', + canonicalParamId: 'objectType', description: 'What you want to watch.', options: [ { label: 'Contact', id: 'contact' }, @@ -82,6 +79,7 @@ export const hubspotPollingTrigger: TriggerConfig = { id: 'customObjectTypeId', title: 'Custom Object Type ID', type: 'short-input', + canonicalParamId: 'customObjectTypeId', description: 'HubSpot custom object type ID (e.g. "2-12345"). Find it in HubSpot Settings → Objects → Custom Objects.', placeholder: '2-12345', @@ -93,24 +91,9 @@ export const hubspotPollingTrigger: TriggerConfig = { id: 'listId', title: 'List', type: 'dropdown', + selectorKey: 'hubspot.lists', description: 'The HubSpot list to watch for new members.', placeholder: 'Select a list', - options: [], - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - if (!credentialId) throw new Error('No HubSpot credential selected') - try { - const data = await requestJson(hubspotListsSelectorContract, { - query: { credentialId }, - }) - return data.lists.map((l) => ({ id: l.id, label: l.name })) - } catch (error) { - logger.error('Error fetching HubSpot lists:', error) - throw error - } - }, dependsOn: ['triggerCredentials'], required: { field: 'objectType', value: 'list_membership' }, mode: 'trigger', @@ -137,19 +120,9 @@ export const hubspotPollingTrigger: TriggerConfig = { id: 'targetPropertyName', title: 'Property to Watch', type: 'dropdown', + selectorKey: 'hubspot.properties', description: 'Fires only when this specific property changes value on a record.', placeholder: 'Select a property', - options: [], - fetchOptions: async (blockId: string) => { - const resolved = await resolveSelectedObjectType(blockId) - if (!resolved) throw new Error('Select an object type first') - try { - return await fetchHubSpotProperties(blockId, resolved) - } catch (error) { - logger.error('Error fetching HubSpot properties:', error) - throw error - } - }, dependsOn: ['triggerCredentials', 'objectType', 'customObjectTypeId'], required: { field: 'eventType', value: 'property_changed' }, mode: 'trigger', @@ -163,21 +136,11 @@ export const hubspotPollingTrigger: TriggerConfig = { id: 'properties', title: 'Properties to Fetch', type: 'dropdown', + selectorKey: 'hubspot.properties', multiSelect: true, description: 'Properties to include on each record. Leave empty to use sensible defaults. Sim always includes the timestamps it needs internally.', placeholder: 'Select properties (optional)', - options: [], - fetchOptions: async (blockId: string) => { - const resolved = await resolveSelectedObjectType(blockId) - if (!resolved) return [] - try { - return await fetchHubSpotProperties(blockId, resolved) - } catch (error) { - logger.error('Error fetching HubSpot properties:', error) - throw error - } - }, dependsOn: ['triggerCredentials', 'objectType', 'customObjectTypeId'], required: false, mode: 'trigger', @@ -187,25 +150,10 @@ export const hubspotPollingTrigger: TriggerConfig = { id: 'pipelineId', title: 'Pipeline (optional)', type: 'dropdown', + canonicalParamId: 'pipelineId', + selectorKey: 'hubspot.pipelines', description: 'Restrict to a single pipeline.', placeholder: 'All pipelines', - options: [], - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - const objectType = (await resolveSelectedObjectType(blockId)) ?? 'contact' - if (!credentialId) throw new Error('No HubSpot credential selected') - try { - const data = await requestJson(hubspotPipelinesSelectorContract, { - query: { credentialId, objectType }, - }) - return data.pipelines.map((p) => ({ id: p.id, label: p.name })) - } catch (error) { - logger.error('Error fetching HubSpot pipelines:', error) - throw error - } - }, dependsOn: ['triggerCredentials', 'objectType'], required: false, mode: 'trigger', @@ -215,28 +163,9 @@ export const hubspotPollingTrigger: TriggerConfig = { id: 'stageId', title: 'Stage (optional)', type: 'dropdown', + selectorKey: 'hubspot.pipelineStages', description: 'Restrict to a single stage within the selected pipeline.', placeholder: 'All stages', - options: [], - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - const objectType = (await resolveSelectedObjectType(blockId)) ?? 'contact' - const pipelineId = (await readSubBlockValue(blockId, 'pipelineId')) as string | null - if (!credentialId) throw new Error('No HubSpot credential selected') - if (!pipelineId) return [] - try { - const data = await requestJson(hubspotPipelinesSelectorContract, { - query: { credentialId, objectType }, - }) - const pipeline = data.pipelines.find((p) => p.id === pipelineId) - return (pipeline?.stages ?? []).map((s) => ({ id: s.id, label: s.label })) - } catch (error) { - logger.error('Error fetching HubSpot stages:', error) - throw error - } - }, dependsOn: ['triggerCredentials', 'objectType', 'pipelineId'], required: false, mode: 'trigger', @@ -246,24 +175,9 @@ export const hubspotPollingTrigger: TriggerConfig = { id: 'ownerId', title: 'Owner (optional)', type: 'dropdown', + selectorKey: 'hubspot.owners', description: 'Restrict to records owned by a specific HubSpot user.', placeholder: 'Any owner', - options: [], - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - if (!credentialId) throw new Error('No HubSpot credential selected') - try { - const data = await requestJson(hubspotOwnersSelectorContract, { - query: { credentialId }, - }) - return data.owners.map((o) => ({ id: o.id, label: o.name })) - } catch (error) { - logger.error('Error fetching HubSpot owners:', error) - throw error - } - }, dependsOn: ['triggerCredentials'], required: false, mode: 'trigger', diff --git a/apps/sim/triggers/outlook/poller.ts b/apps/sim/triggers/outlook/poller.ts index 44200e346d5..859524beaa5 100644 --- a/apps/sim/triggers/outlook/poller.ts +++ b/apps/sim/triggers/outlook/poller.ts @@ -1,8 +1,5 @@ import { createLogger } from '@sim/logger' import { OutlookIcon } from '@/components/icons' -import { requestJson } from '@/lib/api/client/request' -import { outlookFoldersSelectorContract } from '@/lib/api/contracts/selectors/microsoft' -import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('OutlookPollingTrigger') @@ -21,6 +18,7 @@ export const outlookPollingTrigger: TriggerConfig = { id: 'triggerCredentials', title: 'Credentials', type: 'oauth-input', + canonicalParamId: 'oauthCredential', description: 'This trigger requires outlook credentials to access your account.', serviceId: 'outlook', requiredScopes: [], @@ -32,31 +30,11 @@ export const outlookPollingTrigger: TriggerConfig = { title: 'Outlook Folders to Monitor', canvasNoun: 'a folder', type: 'dropdown', + selectorKey: 'outlook.folders', multiSelect: true, placeholder: 'Select Outlook folders to monitor for new emails', description: 'Choose which Outlook folders to monitor. Leave empty to monitor all emails.', required: false, - options: [], // Will be populated dynamically - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - if (!credentialId) { - throw new Error('No Outlook credential selected') - } - try { - const data = await requestJson(outlookFoldersSelectorContract, { - query: { credentialId }, - }) - return data.folders.map((folder) => ({ - id: folder.id, - label: folder.name, - })) - } catch (error) { - logger.error('Error fetching Outlook folders:', error) - throw error - } - }, dependsOn: ['triggerCredentials'], mode: 'trigger', }, diff --git a/apps/sim/triggers/webflow/collection_item_changed.ts b/apps/sim/triggers/webflow/collection_item_changed.ts index 1702e272f16..fcb90387e5c 100644 --- a/apps/sim/triggers/webflow/collection_item_changed.ts +++ b/apps/sim/triggers/webflow/collection_item_changed.ts @@ -1,11 +1,5 @@ import { createLogger } from '@sim/logger' import { WebflowIcon } from '@/components/icons' -import { requestJson } from '@/lib/api/client/request' -import { - webflowCollectionsSelectorContract, - webflowSitesSelectorContract, -} from '@/lib/api/contracts/selectors/webflow' -import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '../types' const logger = createLogger('webflow-collection-item-changed-trigger') @@ -24,6 +18,7 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = { id: 'triggerCredentials', title: 'Credentials', type: 'oauth-input', + canonicalParamId: 'oauthCredential', description: 'This trigger requires webflow credentials to access your account.', serviceId: 'webflow', requiredScopes: [], @@ -38,108 +33,32 @@ export const webflowCollectionItemChangedTrigger: TriggerConfig = { id: 'triggerSiteId', title: 'Site', type: 'dropdown', + canonicalParamId: 'siteId', + selectorKey: 'webflow.sites', placeholder: 'Select a site', description: 'The Webflow site to monitor', required: true, - options: [], mode: 'trigger', condition: { field: 'selectedTriggerId', value: 'webflow_collection_item_changed', }, - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - if (!credentialId) { - throw new Error('No Webflow credential selected') - } - try { - const data = await requestJson(webflowSitesSelectorContract, { - body: { credential: credentialId }, - }) - return (data.sites ?? []).map((site) => ({ - id: site.id, - label: site.name, - })) - } catch (error) { - logger.error('Error fetching Webflow sites:', error) - throw error - } - }, - fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - if (!credentialId) return null - try { - const data = await requestJson(webflowSitesSelectorContract, { - body: { credential: credentialId, siteId: optionId }, - }) - const site = data.sites?.find((s) => s.id === optionId) - if (site) { - return { id: site.id, label: site.name } - } - return null - } catch { - return null - } - }, dependsOn: ['triggerCredentials'], }, { id: 'triggerCollectionId', title: 'Collection', type: 'dropdown', + canonicalParamId: 'collectionId', + selectorKey: 'webflow.collections', placeholder: 'Select a collection (optional)', description: 'Optionally filter to monitor only a specific collection', required: false, - options: [], mode: 'trigger', condition: { field: 'selectedTriggerId', value: 'webflow_collection_item_changed', }, - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null - if (!credentialId || !siteId) { - return [] - } - try { - const data = await requestJson(webflowCollectionsSelectorContract, { - body: { credential: credentialId, siteId }, - }) - return (data.collections ?? []).map((collection) => ({ - id: collection.id, - label: collection.name, - })) - } catch (error) { - logger.error('Error fetching Webflow collections:', error) - throw error - } - }, - fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null - if (!credentialId || !siteId) return null - try { - const data = await requestJson(webflowCollectionsSelectorContract, { - body: { credential: credentialId, siteId }, - }) - const collection = data.collections?.find((c) => c.id === optionId) - if (collection) { - return { id: collection.id, label: collection.name } - } - return null - } catch { - return null - } - }, dependsOn: ['triggerCredentials', 'triggerSiteId'], }, { diff --git a/apps/sim/triggers/webflow/collection_item_created.ts b/apps/sim/triggers/webflow/collection_item_created.ts index b69bf55264a..8c913c4c002 100644 --- a/apps/sim/triggers/webflow/collection_item_created.ts +++ b/apps/sim/triggers/webflow/collection_item_created.ts @@ -1,11 +1,5 @@ import { createLogger } from '@sim/logger' import { WebflowIcon } from '@/components/icons' -import { requestJson } from '@/lib/api/client/request' -import { - webflowCollectionsSelectorContract, - webflowSitesSelectorContract, -} from '@/lib/api/contracts/selectors/webflow' -import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '../types' const logger = createLogger('webflow-collection-item-created-trigger') @@ -39,6 +33,7 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = { id: 'triggerCredentials', title: 'Credentials', type: 'oauth-input', + canonicalParamId: 'oauthCredential', description: 'This trigger requires webflow credentials to access your account.', serviceId: 'webflow', requiredScopes: [], @@ -53,108 +48,32 @@ export const webflowCollectionItemCreatedTrigger: TriggerConfig = { id: 'triggerSiteId', title: 'Site', type: 'dropdown', + canonicalParamId: 'siteId', + selectorKey: 'webflow.sites', placeholder: 'Select a site', description: 'The Webflow site to monitor', required: true, - options: [], mode: 'trigger', condition: { field: 'selectedTriggerId', value: 'webflow_collection_item_created', }, - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - if (!credentialId) { - throw new Error('No Webflow credential selected') - } - try { - const data = await requestJson(webflowSitesSelectorContract, { - body: { credential: credentialId }, - }) - return (data.sites ?? []).map((site) => ({ - id: site.id, - label: site.name, - })) - } catch (error) { - logger.error('Error fetching Webflow sites:', error) - throw error - } - }, - fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - if (!credentialId) return null - try { - const data = await requestJson(webflowSitesSelectorContract, { - body: { credential: credentialId, siteId: optionId }, - }) - const site = data.sites?.find((s) => s.id === optionId) - if (site) { - return { id: site.id, label: site.name } - } - return null - } catch { - return null - } - }, dependsOn: ['triggerCredentials'], }, { id: 'triggerCollectionId', title: 'Collection', type: 'dropdown', + canonicalParamId: 'collectionId', + selectorKey: 'webflow.collections', placeholder: 'Select a collection (optional)', description: 'Optionally filter to monitor only a specific collection', required: false, - options: [], mode: 'trigger', condition: { field: 'selectedTriggerId', value: 'webflow_collection_item_created', }, - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null - if (!credentialId || !siteId) { - return [] - } - try { - const data = await requestJson(webflowCollectionsSelectorContract, { - body: { credential: credentialId, siteId }, - }) - return (data.collections ?? []).map((collection) => ({ - id: collection.id, - label: collection.name, - })) - } catch (error) { - logger.error('Error fetching Webflow collections:', error) - throw error - } - }, - fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null - if (!credentialId || !siteId) return null - try { - const data = await requestJson(webflowCollectionsSelectorContract, { - body: { credential: credentialId, siteId }, - }) - const collection = data.collections?.find((c) => c.id === optionId) - if (collection) { - return { id: collection.id, label: collection.name } - } - return null - } catch { - return null - } - }, dependsOn: ['triggerCredentials', 'triggerSiteId'], }, { diff --git a/apps/sim/triggers/webflow/collection_item_deleted.ts b/apps/sim/triggers/webflow/collection_item_deleted.ts index 3d1acc00c26..baffaae5a8a 100644 --- a/apps/sim/triggers/webflow/collection_item_deleted.ts +++ b/apps/sim/triggers/webflow/collection_item_deleted.ts @@ -1,11 +1,5 @@ import { createLogger } from '@sim/logger' import { WebflowIcon } from '@/components/icons' -import { requestJson } from '@/lib/api/client/request' -import { - webflowCollectionsSelectorContract, - webflowSitesSelectorContract, -} from '@/lib/api/contracts/selectors/webflow' -import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '../types' const logger = createLogger('webflow-collection-item-deleted-trigger') @@ -24,6 +18,7 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = { id: 'triggerCredentials', title: 'Credentials', type: 'oauth-input', + canonicalParamId: 'oauthCredential', description: 'This trigger requires webflow credentials to access your account.', serviceId: 'webflow', requiredScopes: [], @@ -38,108 +33,32 @@ export const webflowCollectionItemDeletedTrigger: TriggerConfig = { id: 'triggerSiteId', title: 'Site', type: 'dropdown', + canonicalParamId: 'siteId', + selectorKey: 'webflow.sites', placeholder: 'Select a site', description: 'The Webflow site to monitor', required: true, - options: [], mode: 'trigger', condition: { field: 'selectedTriggerId', value: 'webflow_collection_item_deleted', }, - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - if (!credentialId) { - throw new Error('No Webflow credential selected') - } - try { - const data = await requestJson(webflowSitesSelectorContract, { - body: { credential: credentialId }, - }) - return (data.sites ?? []).map((site) => ({ - id: site.id, - label: site.name, - })) - } catch (error) { - logger.error('Error fetching Webflow sites:', error) - throw error - } - }, - fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - if (!credentialId) return null - try { - const data = await requestJson(webflowSitesSelectorContract, { - body: { credential: credentialId, siteId: optionId }, - }) - const site = data.sites?.find((s) => s.id === optionId) - if (site) { - return { id: site.id, label: site.name } - } - return null - } catch { - return null - } - }, dependsOn: ['triggerCredentials'], }, { id: 'triggerCollectionId', title: 'Collection', type: 'dropdown', + canonicalParamId: 'collectionId', + selectorKey: 'webflow.collections', placeholder: 'Select a collection (optional)', description: 'Optionally filter to monitor only a specific collection', required: false, - options: [], mode: 'trigger', condition: { field: 'selectedTriggerId', value: 'webflow_collection_item_deleted', }, - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null - if (!credentialId || !siteId) { - return [] - } - try { - const data = await requestJson(webflowCollectionsSelectorContract, { - body: { credential: credentialId, siteId }, - }) - return (data.collections ?? []).map((collection) => ({ - id: collection.id, - label: collection.name, - })) - } catch (error) { - logger.error('Error fetching Webflow collections:', error) - throw error - } - }, - fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - const siteId = (await readSubBlockValue(blockId, 'triggerSiteId')) as string | null - if (!credentialId || !siteId) return null - try { - const data = await requestJson(webflowCollectionsSelectorContract, { - body: { credential: credentialId, siteId }, - }) - const collection = data.collections?.find((c) => c.id === optionId) - if (collection) { - return { id: collection.id, label: collection.name } - } - return null - } catch { - return null - } - }, dependsOn: ['triggerCredentials', 'triggerSiteId'], }, { diff --git a/apps/sim/triggers/webflow/form_submission.ts b/apps/sim/triggers/webflow/form_submission.ts index 286ad9f5738..13769147f71 100644 --- a/apps/sim/triggers/webflow/form_submission.ts +++ b/apps/sim/triggers/webflow/form_submission.ts @@ -1,8 +1,5 @@ import { createLogger } from '@sim/logger' import { WebflowIcon } from '@/components/icons' -import { requestJson } from '@/lib/api/client/request' -import { webflowSitesSelectorContract } from '@/lib/api/contracts/selectors/webflow' -import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '../types' const logger = createLogger('webflow-form-submission-trigger') @@ -21,6 +18,7 @@ export const webflowFormSubmissionTrigger: TriggerConfig = { id: 'triggerCredentials', title: 'Credentials', type: 'oauth-input', + canonicalParamId: 'oauthCredential', description: 'This trigger requires webflow credentials to access your account.', serviceId: 'webflow', requiredScopes: ['forms:read'], @@ -35,53 +33,16 @@ export const webflowFormSubmissionTrigger: TriggerConfig = { id: 'triggerSiteId', title: 'Site', type: 'dropdown', + canonicalParamId: 'siteId', + selectorKey: 'webflow.sites', placeholder: 'Select a site', description: 'The Webflow site to monitor', required: true, - options: [], mode: 'trigger', condition: { field: 'selectedTriggerId', value: 'webflow_form_submission', }, - fetchOptions: async (blockId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - if (!credentialId) { - throw new Error('No Webflow credential selected') - } - try { - const data = await requestJson(webflowSitesSelectorContract, { - body: { credential: credentialId }, - }) - return (data.sites ?? []).map((site) => ({ - id: site.id, - label: site.name, - })) - } catch (error) { - logger.error('Error fetching Webflow sites:', error) - throw error - } - }, - fetchOptionById: async (blockId: string, optionId: string) => { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as - | string - | null - if (!credentialId) return null - try { - const data = await requestJson(webflowSitesSelectorContract, { - body: { credential: credentialId, siteId: optionId }, - }) - const site = data.sites?.find((s) => s.id === optionId) - if (site) { - return { id: site.id, label: site.name } - } - return null - } catch { - return null - } - }, dependsOn: ['triggerCredentials'], }, { From 454174a35d98e9d79c1ba0c7c25794a2955c446a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 19:24:00 -0700 Subject: [PATCH 06/14] refactor(triggers): move the table trigger's column picker onto table.columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fetchTableColumns` resolved the workspace from the active-workflow store and the table id by reading two subblocks by name, then refetched the table list to find one table's schema. The registered `table.columns` selector takes both from the context — `tableSelector`/`manualTableId` already carry `canonicalParamId: 'tableId'`, so the canonical pair resolves on its own — and reads the table detail query directly. Deletes the helper and the four imports it was the only user of. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/triggers/table/poller.ts | 35 +------------------------------ 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/apps/sim/triggers/table/poller.ts b/apps/sim/triggers/table/poller.ts index f11cad09806..99b9ed88c46 100644 --- a/apps/sim/triggers/table/poller.ts +++ b/apps/sim/triggers/table/poller.ts @@ -1,38 +1,6 @@ import { Table } from '@sim/emcn/icons' -import { requestJson } from '@/lib/api/client/request' -import { listTablesContract } from '@/lib/api/contracts/tables' -import type { TableDefinition } from '@/lib/table' -import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { tableKeys } from '@/hooks/queries/utils/table-keys' -import { readActiveWorkflowContext, readBlockValues } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' -async function fetchTableColumns(blockId: string): Promise> { - const { activeWorkflowId, workspaceId } = await readActiveWorkflowContext() - if (!activeWorkflowId || !workspaceId) return [] - - const blockValues = await readBlockValues(blockId) - const tableId = (blockValues?.tableSelector as string) || (blockValues?.manualTableId as string) - if (!tableId) return [] - - const tables = await getQueryClient().fetchQuery({ - queryKey: tableKeys.list(workspaceId), - queryFn: async ({ signal }): Promise => { - const response = await requestJson(listTablesContract, { - query: { workspaceId, scope: 'active' }, - signal, - }) - return (response.data.tables ?? []) as TableDefinition[] - }, - staleTime: 60 * 1000, - }) - - const table = tables.find((t: TableDefinition) => t.id === tableId) - if (!table?.schema?.columns) return [] - - return table.schema.columns.map((col) => ({ id: col.name, label: col.name })) -} - export const tableNewRowTrigger: TriggerConfig = { id: 'table_new_row', name: 'Table Trigger', @@ -79,15 +47,14 @@ export const tableNewRowTrigger: TriggerConfig = { id: 'watchColumns', title: 'Watch Columns', type: 'dropdown', + selectorKey: 'table.columns', multiSelect: true, - options: [], placeholder: 'All columns', description: 'Only fire when these columns change. Leave empty to fire on any update.', required: false, mode: 'trigger', condition: { field: 'eventType', value: 'update' }, dependsOn: { any: ['tableSelector', 'manualTableId'] }, - fetchOptions: fetchTableColumns, }, { id: 'includeHeaders', From ca070bf473eacdd6499d0aee14c6ea0c86eff8b1 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 19:30:09 -0700 Subject: [PATCH 07/14] refactor(managed-agent): move its four pickers onto registered selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four read one route distinguished only by `resource`, with the credential pulled from the store by name. They are now `managedAgent.agents` / `.vaults` / `.memoryStores` / `.environments`, and `lib/managed-agents/subblock-options.ts` is deleted entirely. The environment filter (cloud vs self_hosted expose different fields, so mixing them offers choices the rest of the form cannot honour) moves into the selector with `environmentType` on the context. Also decouples two things `canonicalParamId` was conflating. It is both a block's serialized PARAM NAME and the key `buildSelectorContextFromBlock` reads, so making this block's pickers resolvable appeared to require renaming its shipped `credential` param to `oauthCredential` — a rename that would change the serialized shape of every existing managed_agent block, and one that `blocks.test.ts` correctly refused. A picker should not be able to force a param rename, so the context now reads a credential off the subblock TYPE when no canonical id supplied one. It only fills a gap: a block that declares `canonicalParamId: 'oauthCredential'` has already resolved it, including the basic/advanced active-member logic the type check cannot express. Co-Authored-By: Claude Opus 5 (1M context) --- apps/sim/blocks/blocks/managed_agent.ts | 19 ++--- .../providers/managed-agent/selectors.ts | 77 +++++++++++++++++++ apps/sim/hooks/selectors/registry.ts | 2 + apps/sim/hooks/selectors/types.ts | 9 +++ .../lib/managed-agents/subblock-options.ts | 64 --------------- apps/sim/lib/workflows/subblocks/context.ts | 22 ++++++ 6 files changed, 115 insertions(+), 78 deletions(-) create mode 100644 apps/sim/hooks/selectors/providers/managed-agent/selectors.ts delete mode 100644 apps/sim/lib/managed-agents/subblock-options.ts diff --git a/apps/sim/blocks/blocks/managed_agent.ts b/apps/sim/blocks/blocks/managed_agent.ts index 4b17314ab61..3cac5875fda 100644 --- a/apps/sim/blocks/blocks/managed_agent.ts +++ b/apps/sim/blocks/blocks/managed_agent.ts @@ -1,10 +1,4 @@ import { ClaudeIcon } from '@/components/icons' -import { - fetchManagedAgentAgentOptions, - fetchManagedAgentEnvironmentOptions, - fetchManagedAgentMemoryStoreOptions, - fetchManagedAgentVaultOptions, -} from '@/lib/managed-agents/subblock-options' import type { BlockConfig, BlockMeta } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' @@ -264,6 +258,7 @@ export const ManagedAgentBlock: BlockConfig = { id: 'environmentType', title: 'Environment type', type: 'dropdown', + canonicalParamId: 'environmentType', required: true, condition: forOperations(SESSION_STARTING_OPERATIONS), options: [ @@ -278,25 +273,23 @@ export const ManagedAgentBlock: BlockConfig = { id: 'agent', title: 'Agent', type: 'combobox', + selectorKey: 'managedAgent.agents', required: true, placeholder: 'Select an agent from your Claude workspace…', commandSearchable: true, - options: [], dependsOn: ['credential'], condition: forOperations(SESSION_STARTING_OPERATIONS), - fetchOptions: fetchManagedAgentAgentOptions, }, { id: 'environment', title: 'Environment', type: 'combobox', + selectorKey: 'managedAgent.environments', required: true, placeholder: 'Select an environment…', commandSearchable: true, - options: [], dependsOn: ['credential', 'environmentType'], condition: forOperations(SESSION_STARTING_OPERATIONS), - fetchOptions: fetchManagedAgentEnvironmentOptions, }, { id: 'userMessage', @@ -407,15 +400,14 @@ export const ManagedAgentBlock: BlockConfig = { id: 'vaults', title: 'Credential vaults', type: 'dropdown', + selectorKey: 'managedAgent.vaults', required: false, mode: 'advanced', placeholder: 'Optional — pick zero or more OAuth vaults', searchable: true, multiSelect: true, - options: [], dependsOn: ['credential'], condition: forOperations(SESSION_STARTING_OPERATIONS), - fetchOptions: fetchManagedAgentVaultOptions, }, { id: 'vaultsAck', @@ -431,11 +423,11 @@ export const ManagedAgentBlock: BlockConfig = { id: 'memoryStoreId', title: 'Memory store', type: 'combobox', + selectorKey: 'managedAgent.memoryStores', required: false, mode: 'advanced', placeholder: 'Optional — pick a memory store', commandSearchable: true, - options: [], dependsOn: ['credential'], // Cloud only: memory stores attach as `resources[]`, which self-hosted // rejects. A self-hosted worker that uses a store reads its id from a @@ -444,7 +436,6 @@ export const ManagedAgentBlock: BlockConfig = { field: 'environmentType', value: 'cloud', }), - fetchOptions: fetchManagedAgentMemoryStoreOptions, }, { id: 'memoryAccess', diff --git a/apps/sim/hooks/selectors/providers/managed-agent/selectors.ts b/apps/sim/hooks/selectors/providers/managed-agent/selectors.ts new file mode 100644 index 00000000000..cc452e0e7c2 --- /dev/null +++ b/apps/sim/hooks/selectors/providers/managed-agent/selectors.ts @@ -0,0 +1,77 @@ +import { requestJson } from '@/lib/api/client/request' +import { + listManagedAgentOptionsContract, + type ManagedAgentResource, +} from '@/lib/api/contracts/managed-agents' +import { ensureCredential, SELECTOR_STALE } from '@/hooks/selectors/providers/shared' +import type { + SelectorDefinition, + SelectorKey, + SelectorOption, + SelectorQueryArgs, +} from '@/hooks/selectors/types' + +/** + * All four Managed Agent pickers read one route, distinguished only by `resource`. The route + * decrypts the selected Claude Platform credential server-side, so the API key never reaches + * the browser — which is why these cannot fall back to a plain client fetch. + */ +async function listResource( + key: SelectorKey, + resource: ManagedAgentResource, + { context, signal }: SelectorQueryArgs +): Promise { + const credentialId = ensureCredential(context, key) + const { options } = await requestJson(listManagedAgentOptionsContract, { + query: { credentialId, resource }, + signal, + }) + return options +} + +function resourceSelector(key: SelectorKey, resource: ManagedAgentResource): SelectorDefinition { + return { + key, + contracts: [listManagedAgentOptionsContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + key, + context.oauthCredential ?? 'none', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + fetchList: (args: SelectorQueryArgs) => listResource(key, resource, args), + } +} + +export const managedAgentSelectors = { + 'managedAgent.agents': resourceSelector('managedAgent.agents', 'agents'), + 'managedAgent.vaults': resourceSelector('managedAgent.vaults', 'vaults'), + 'managedAgent.memoryStores': resourceSelector('managedAgent.memoryStores', 'memory-stores'), + /** + * Environments are filtered to the selected deployment mode: cloud and self-hosted expose + * different fields (self-hosted rejects `resources`), so mixing them offers choices the rest + * of the form cannot honour. An option whose type the API leaves unset is kept either way. + */ + 'managedAgent.environments': { + key: 'managedAgent.environments', + contracts: [listManagedAgentOptionsContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'managedAgent.environments', + context.oauthCredential ?? 'none', + context.environmentType ?? 'any', + ], + enabled: ({ context }) => Boolean(context.oauthCredential), + fetchList: async (args: SelectorQueryArgs) => { + const options = await listResource('managedAgent.environments', 'environments', args) + const mode = args.context.environmentType + if (mode !== 'cloud' && mode !== 'self_hosted') return options + return options.filter((option) => { + const type = (option as { type?: string }).type + return type === undefined || type === mode + }) + }, + }, +} satisfies Partial> diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index 9d1cc1b686c..f6635fe088c 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -13,6 +13,7 @@ import { jiraSelectors } from '@/hooks/selectors/providers/jira/selectors' import { jsmSelectors } from '@/hooks/selectors/providers/jsm/selectors' import { knowledgeSelectors } from '@/hooks/selectors/providers/knowledge/selectors' import { linearSelectors } from '@/hooks/selectors/providers/linear/selectors' +import { managedAgentSelectors } from '@/hooks/selectors/providers/managed-agent/selectors' import { microsoftSelectors } from '@/hooks/selectors/providers/microsoft/selectors' import { mondaySelectors } from '@/hooks/selectors/providers/monday/selectors' import { netsuiteSelectors } from '@/hooks/selectors/providers/netsuite/selectors' @@ -45,6 +46,7 @@ export const selectorRegistry = { ...jsmSelectors, ...googleSelectors, ...hubspotSelectors, + ...managedAgentSelectors, ...microsoftSelectors, ...notionSelectors, ...pipedriveSelectors, diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index 840640c3c95..830a2582ce0 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -20,6 +20,10 @@ export type SelectorKey = | 'clickup.lists' | 'confluence.spaces' | 'google.tasks.lists' + | 'managedAgent.agents' + | 'managedAgent.environments' + | 'managedAgent.vaults' + | 'managedAgent.memoryStores' | 'hubspot.lists' | 'hubspot.owners' | 'hubspot.pipelines' @@ -140,6 +144,11 @@ export interface SelectorContext { customObjectTypeId?: string /** HubSpot pipeline whose stages a stage picker enumerates. */ pipelineId?: string + /** + * Managed Agent deployment mode (`cloud` | `self_hosted`). The two expose different fields, + * so an environment list is filtered to the selected mode rather than mixing them. + */ + environmentType?: string } export interface SelectorQueryArgs { diff --git a/apps/sim/lib/managed-agents/subblock-options.ts b/apps/sim/lib/managed-agents/subblock-options.ts deleted file mode 100644 index ca25119ef56..00000000000 --- a/apps/sim/lib/managed-agents/subblock-options.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { requestJson } from '@/lib/api/client/request' -import { - listManagedAgentOptionsContract, - type ManagedAgentOption, - type ManagedAgentResource, -} from '@/lib/api/contracts/managed-agents' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' - -/** - * `fetchOptions` helpers for the Managed Agent block's dropdowns. Each reads - * the block's selected Claude Platform `credential` and calls the list route, - * which decrypts the credential's key server-side — the API key never touches - * the browser. - */ - -function readSubBlockValue(blockId: string, key: string): string | null { - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (!activeWorkflowId) return null - const value = useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId]?.[key] - return typeof value === 'string' && value.length > 0 ? value : null -} - -async function fetchOptions( - blockId: string, - resource: ManagedAgentResource -): Promise { - const credentialId = readSubBlockValue(blockId, 'credential') - if (!credentialId) return [] - try { - const { options } = await requestJson(listManagedAgentOptionsContract, { - query: { credentialId, resource }, - }) - return options - } catch { - return [] - } -} - -export function fetchManagedAgentAgentOptions(blockId: string): Promise { - return fetchOptions(blockId, 'agents') -} - -export async function fetchManagedAgentEnvironmentOptions( - blockId: string -): Promise { - const options = await fetchOptions(blockId, 'environments') - // Filter to the selected environment type so cloud/self-hosted stay separate - // (self-hosted rejects `resources`, so the two modes expose different fields). - // Options with an unknown type are kept as a safety net. - const mode = readSubBlockValue(blockId, 'environmentType') - if (mode !== 'cloud' && mode !== 'self_hosted') return options - return options.filter((option) => option.type === undefined || option.type === mode) -} - -export function fetchManagedAgentVaultOptions(blockId: string): Promise { - return fetchOptions(blockId, 'vaults') -} - -export function fetchManagedAgentMemoryStoreOptions( - blockId: string -): Promise { - return fetchOptions(blockId, 'memory-stores') -} diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index 35156ced0a9..09f69cbb7ec 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -45,6 +45,7 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ 'objectType', 'customObjectTypeId', 'pipelineId', + 'environmentType', ]) /** @@ -94,5 +95,26 @@ export function buildSelectorContextFromBlock( setField(subBlockId, subBlock?.value) } + // A credential field IS the oauth credential, whatever the block calls its subblock. Most + // blocks say so with `canonicalParamId: 'oauthCredential'`, but that id is also the block's + // serialized param name — so requiring it would mean renaming a shipped block's param just + // to make its pickers resolvable, which is not a rename any picker should be able to force. + // Reading it off the subblock TYPE keeps the two decisions independent. + // + // Only fills a gap: a block that does declare the canonical id has already set it above, + // including the basic/advanced active-member resolution this loop cannot express. + if (!context.oauthCredential) { + for (const [subBlockId, subBlock] of Object.entries(subBlocks)) { + if (blockConfig.subBlocks.find((cfg) => cfg.id === subBlockId)?.type !== 'oauth-input') { + continue + } + const value = subBlock?.value + if (typeof value === 'string' && value) { + context.oauthCredential = value + break + } + } + } + return context } From 3c759a2b0513790c850d93cf07bd148c708af014 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 19:47:49 -0700 Subject: [PATCH 08/14] =?UTF-8?q?refactor(sub-blocks):=20delete=20fetchOpt?= =?UTF-8?q?ions=20=E2=80=94=20a=20sub-block's=20options=20are=20a=20select?= =?UTF-8?q?or=20or=20derived,=20never=20both?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the migration. `fetchOptions`/`fetchOptionById` are off `SubBlockConfig`, off both controls, and out of `useFetchedOptions`, leaving exactly two ways a sub-block gets its options: selectorKey — a registered selector. The ONLY way to load a remote list. Parameterized by an explicit SelectorContext, so it works on the canvas, in the fork sync modal, and anywhere else. options — a static array, or a pure function of the block's own values. No I/O. Reading the remaining callsites showed most of the "derived" ones were nothing of the kind — they were workspace-scoped remote fetches wearing a local-looking signature. Those became seven `workspace.*` selectors (credential providers, credential groups + their per-group providers, secret names, raw secret names, sandboxes, trigger types) plus `providers.openrouterEmbeddingModels`. Only the agent block's three capability dropdowns were genuinely derived; `options` now takes the block's values so they can say so directly. The parameter is optional, so every existing zero-argument options function is untouched. `imap.mailboxes` is the one selector whose account is typed rather than stored. Its password is deliberately absent from the query key: a query key identifies a resource, a credential authorizes access to it. `oauthCredential` is safe there because it is only an id — a typed password is a secret, and keys are cached and surfaced by devtools. Host, port, TLS and username already identify the mailbox list uniquely; the password rides the body exactly as before. `selectorExcludeSelf` replaces the one thing a shared `sim.workflows` selector could not express. It is a declared flag rather than a blanket rule because the answer differs per field: the Sim trigger never receives events about its own workflow, while the Logs block legitimately reads the logs of the workflow it runs in. Deletes `lib/workflows/subblocks/options.ts` and `triggers/editor-state.ts` entirely — every caller was a `fetchOptions` resolver. The live-registry test for the trigger vocabulary moves to the selector that now owns it, keeping its lazy-import cycle guarantee under test. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/combobox/combobox.tsx | 19 +- .../components/dropdown/dropdown.tsx | 29 +-- .../sub-block/hooks/use-fetched-options.ts | 21 +- .../editor/components/sub-block/sub-block.tsx | 6 +- apps/sim/blocks/blocks.test.ts | 2 +- apps/sim/blocks/blocks/agent.ts | 134 ++-------- .../blocks/blocks/credential-group.test.ts | 31 ++- apps/sim/blocks/blocks/credential-group.ts | 47 +--- apps/sim/blocks/blocks/credential.ts | 38 +-- apps/sim/blocks/blocks/embeddings.test.ts | 7 +- apps/sim/blocks/blocks/embeddings.ts | 12 +- apps/sim/blocks/blocks/function.ts | 12 +- apps/sim/blocks/blocks/logs.ts | 10 +- apps/sim/blocks/blocks/mothership.ts | 4 +- apps/sim/blocks/types.ts | 28 ++- .../queries/dynamic-subblock-options.test.tsx | 39 ++- .../hooks/queries/dynamic-subblock-options.ts | 17 +- .../selectors/providers/imap/selectors.ts | 51 ++++ .../providers/workspace/selectors.ts | 237 ++++++++++++++++++ apps/sim/hooks/selectors/registry.ts | 8 + .../selectors/trigger-types-live.test.ts} | 12 +- apps/sim/hooks/selectors/types.ts | 22 ++ apps/sim/lib/workflows/subblocks/context.ts | 7 + .../lib/workflows/subblocks/options.test.ts | 144 ----------- apps/sim/lib/workflows/subblocks/options.ts | 172 ------------- apps/sim/triggers/clickup/subblocks.ts | 27 -- apps/sim/triggers/editor-state.ts | 61 ----- apps/sim/triggers/hubspot/poller.ts | 29 --- apps/sim/triggers/imap/poller.ts | 38 +-- apps/sim/triggers/sim/workspace-event.ts | 5 +- 30 files changed, 491 insertions(+), 778 deletions(-) create mode 100644 apps/sim/hooks/selectors/providers/imap/selectors.ts create mode 100644 apps/sim/hooks/selectors/providers/workspace/selectors.ts rename apps/sim/{lib/workflows/subblocks/trigger-options-live.test.ts => hooks/selectors/trigger-types-live.test.ts} (73%) delete mode 100644 apps/sim/lib/workflows/subblocks/options.test.ts delete mode 100644 apps/sim/lib/workflows/subblocks/options.ts delete mode 100644 apps/sim/triggers/editor-state.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 92226dd6529..a02bf4c5386 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -71,13 +71,8 @@ interface ComboBoxProps { config: SubBlockConfig /** Registered selector supplying the options. The canonical source for a remote list. */ selectorKey?: SelectorKey - /** Async function to fetch options dynamically */ - fetchOptions?: (blockId: string) => Promise> - /** Async function to fetch a single option's label by ID (for hydration) */ - fetchOptionById?: ( - blockId: string, - optionId: string - ) => Promise<{ label: string; id: string } | null> + /** Drop the hosting workflow from a `sim.workflows` list. */ + selectorExcludeSelf?: boolean /** Field dependencies that trigger option refetch when changed */ dependsOn?: SubBlockConfig['dependsOn'] } @@ -94,8 +89,7 @@ export const ComboBox = memo(function ComboBox({ placeholder = 'Type or select an option...', config, selectorKey, - fetchOptions, - fetchOptionById, + selectorExcludeSelf, dependsOn, }: ComboBoxProps) { const activeSearchTarget = useActiveSearchTarget() @@ -136,8 +130,7 @@ export const ComboBox = memo(function ComboBox({ blockId, dependsOnFields, selectorKey, - fetchOptions, - fetchOptionById, + selectorExcludeSelf, isPreview: Boolean(isPreview), disabled: Boolean(disabled), valueToHydrate: value as string | null | undefined, @@ -202,7 +195,7 @@ export const ComboBox = memo(function ComboBox({ let opts: ComboBoxOption[] = isDynamic && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions - if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) { + if (subBlockId === 'model' && isDynamic && normalizedFetchedOptions.length > 0) { opts = opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id)) } @@ -230,7 +223,7 @@ export const ComboBox = memo(function ComboBox({ return opts }, [ - fetchOptions, + isDynamic, normalizedFetchedOptions, staticOptions, hydratedOption, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 8d406ebbcfc..5ee74ba37ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -17,6 +17,8 @@ import type { SubBlockConfig } from '@/blocks/types' import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' import type { SelectorKey } from '@/hooks/selectors/types' import { useOperationAccess } from '@/hooks/use-operation-access' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' /** Selected-value badges shown before folding the rest into a "+N" badge. */ @@ -41,7 +43,7 @@ type DropdownOption = */ interface DropdownProps { /** Static options array or function that returns options */ - options: DropdownOption[] | (() => DropdownOption[]) + options: DropdownOption[] | ((params?: { values: Record }) => DropdownOption[]) /** Default value to select when no value is set */ defaultValue?: string /** Unique identifier for the block */ @@ -62,13 +64,8 @@ interface DropdownProps { multiSelect?: boolean /** Registered selector supplying the options. The canonical source for a remote list. */ selectorKey?: SelectorKey - /** Async function to fetch options dynamically */ - fetchOptions?: (blockId: string) => Promise> - /** Async function to fetch a single option's label by ID (for hydration) */ - fetchOptionById?: ( - blockId: string, - optionId: string - ) => Promise<{ label: string; id: string } | null> + /** Drop the hosting workflow from a `sim.workflows` list. */ + selectorExcludeSelf?: boolean /** Field dependencies that trigger option refetch when changed */ dependsOn?: SubBlockConfig['dependsOn'] /** Enable search input in dropdown */ @@ -98,8 +95,7 @@ export const Dropdown = memo(function Dropdown({ placeholder = 'Select an option...', multiSelect = false, selectorKey, - fetchOptions, - fetchOptionById, + selectorExcludeSelf, dependsOn, searchable = false, preserveLabelCase = false, @@ -140,9 +136,15 @@ export const Dropdown = memo(function Dropdown({ : [] : null + // Derived option lists read the block's own values (a model's valid reasoning efforts); + // `dependsOn` already re-renders this control when one of those siblings changes. + const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + const blockValues = useSubBlockStore((state) => + activeWorkflowId ? state.workflowValues[activeWorkflowId]?.[blockId] : undefined + ) const evaluatedOptions = useMemo(() => { - return typeof options === 'function' ? options() : options - }, [options]) + return typeof options === 'function' ? options({ values: blockValues ?? {} }) : options + }, [options, blockValues]) const { fetchedOptions, @@ -155,8 +157,7 @@ export const Dropdown = memo(function Dropdown({ blockId, dependsOnFields, selectorKey, - fetchOptions, - fetchOptionById, + selectorExcludeSelf, isPreview: Boolean(isPreview), disabled: Boolean(disabled), valueToHydrate: singleValue, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts index e2d2fb88ab8..a919c965318 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts @@ -33,8 +33,8 @@ interface UseFetchedOptionsProps { * work on the canvas, and was in every case a duplicate of a selector that already existed. */ selectorKey?: SelectorKey - fetchOptions?: (blockId: string) => Promise - fetchOptionById?: (blockId: string, optionId: string) => Promise + /** Drop the hosting workflow from the list — see `SubBlockConfig.selectorExcludeSelf`. */ + selectorExcludeSelf?: boolean isPreview: boolean disabled: boolean /** @@ -82,8 +82,7 @@ export function useFetchedOptions({ blockId, dependsOnFields, selectorKey, - fetchOptions: fetchOptionsProp, - fetchOptionById: fetchOptionByIdProp, + selectorExcludeSelf, isPreview, disabled, valueToHydrate, @@ -128,12 +127,14 @@ export function useFetchedOptions({ : {} const merged: Record = { ...(block.subBlocks ?? {}) } for (const [id, value] of Object.entries(live)) merged[id] = { ...merged[id], value } - return buildSelectorContextFromBlock(block.type, merged, { + const context = buildSelectorContextFromBlock(block.type, merged, { workflowId: activeWorkflowId ?? undefined, workspaceId: workspaceId ?? undefined, canonicalModes: block.data?.canonicalModes, }) - }, [blockId, activeWorkflowId, workspaceId]) + if (selectorExcludeSelf && activeWorkflowId) context.excludeWorkflowId = activeWorkflowId + return context + }, [blockId, activeWorkflowId, workspaceId, selectorExcludeSelf]) const selectorDefinition = selectorKey ? getSelectorDefinition(selectorKey) : undefined @@ -146,7 +147,6 @@ export function useFetchedOptions({ * new identity and the scope reset below refetches, exactly as it does for a prop fetcher. */ const fetchOptions = useMemo(() => { - if (fetchOptionsProp) return fetchOptionsProp if (!selectorDefinition) return undefined const definition = selectorDefinition return async (): Promise => { @@ -162,11 +162,10 @@ export function useFetchedOptions({ return options.map((option) => ({ id: option.id, label: option.label })) } // eslint-disable-next-line react-hooks/exhaustive-deps -- dependencyValues is the refetch scope - }, [fetchOptionsProp, selectorDefinition, readSelectorContext, dependencyValues]) + }, [selectorDefinition, readSelectorContext, dependencyValues]) /** Label hydration for a stored id, from the same definition. */ const fetchOptionById = useMemo(() => { - if (fetchOptionByIdProp) return fetchOptionByIdProp const definition = selectorDefinition const fetchById = definition?.fetchById if (!definition || !fetchById) return undefined @@ -176,7 +175,7 @@ export function useFetchedOptions({ const option = await fetchById({ key: definition.key, context, detailId: optionId, signal }) return option ? { id: option.id, label: option.label } : null } - }, [fetchOptionByIdProp, selectorDefinition, readSelectorContext]) + }, [selectorDefinition, readSelectorContext]) const [fetchedOptions, setFetchedOptions] = useState([]) const [isLoadingOptions, setIsLoadingOptions] = useState(false) @@ -204,7 +203,7 @@ export function useFetchedOptions({ setIsLoadingOptions(true) setFetchError(null) try { - const options = await fetchOptions(blockId) + const options = await fetchOptions() if (requestId !== fetchRequestIdRef.current) return setFetchedOptions(options) } catch (error) { 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 e2dd0313e45..fbbc224651b 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 @@ -681,8 +681,7 @@ function SubBlockComponent({ disabled={isDisabled} multiSelect={config.multiSelect} selectorKey={config.selectorKey} - fetchOptions={config.fetchOptions} - fetchOptionById={config.fetchOptionById} + selectorExcludeSelf={config.selectorExcludeSelf} dependsOn={config.dependsOn} searchable={config.searchable} preserveLabelCase={config.preserveLabelCase} @@ -717,8 +716,7 @@ function SubBlockComponent({ disabled={isDisabled} config={config} selectorKey={config.selectorKey} - fetchOptions={config.fetchOptions} - fetchOptionById={config.fetchOptionById} + selectorExcludeSelf={config.selectorExcludeSelf} dependsOn={config.dependsOn} /> diff --git a/apps/sim/blocks/blocks.test.ts b/apps/sim/blocks/blocks.test.ts index 9fde09f38c3..1ab2265f55e 100644 --- a/apps/sim/blocks/blocks.test.ts +++ b/apps/sim/blocks/blocks.test.ts @@ -904,7 +904,7 @@ describe.concurrent('Blocks Module', () => { (sb) => sb.id === 'model' && sb.condition?.value === provider ) if (provider === 'openrouter') { - expect(modelSubBlock?.fetchOptions).toBeTypeOf('function') + expect(modelSubBlock?.selectorKey).toBeTypeOf('string') } else { expect( Array.isArray(modelSubBlock?.options) ? modelSubBlock.options.length : 0 diff --git a/apps/sim/blocks/blocks/agent.ts b/apps/sim/blocks/blocks/agent.ts index f16bcb57b38..b09eae6e203 100644 --- a/apps/sim/blocks/blocks/agent.ts +++ b/apps/sim/blocks/blocks/agent.ts @@ -25,8 +25,6 @@ import { isAutoModel, supportsTemperature, } from '@/providers/models' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' import type { ToolResponse } from '@/tools/types' const logger = createLogger('AgentBlock') @@ -176,49 +174,19 @@ Return ONLY the JSON array.`, title: 'Reasoning Effort', type: 'combobox', placeholder: 'Type or select reasoning effort...', - options: [ - { label: 'auto', id: 'auto' }, - { label: 'low', id: 'low' }, - { label: 'medium', id: 'medium' }, - { label: 'high', id: 'high' }, - ], dependsOn: ['model'], - fetchOptions: async (blockId: string) => { + options: (params) => { const autoOption = { label: 'auto', id: 'auto' } - - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (!activeWorkflowId) { - return [ - autoOption, - { label: 'low', id: 'low' }, - { label: 'medium', id: 'medium' }, - { label: 'high', id: 'high' }, - ] - } - - const workflowValues = useSubBlockStore.getState().workflowValues[activeWorkflowId] - const blockValues = workflowValues?.[blockId] - const modelValue = blockValues?.model as string - - if (!modelValue) { - return [ - autoOption, - { label: 'low', id: 'low' }, - { label: 'medium', id: 'medium' }, - { label: 'high', id: 'high' }, - ] - } - + const fallback = [ + autoOption, + { label: 'low', id: 'low' }, + { label: 'medium', id: 'medium' }, + { label: 'high', id: 'high' }, + ] + const modelValue = params?.values.model + if (typeof modelValue !== 'string' || !modelValue) return fallback const validOptions = getReasoningEffortValuesForModel(modelValue) - if (!validOptions) { - return [ - autoOption, - { label: 'low', id: 'low' }, - { label: 'medium', id: 'medium' }, - { label: 'high', id: 'high' }, - ] - } - + if (!validOptions) return fallback return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))] }, mode: 'advanced', @@ -229,49 +197,19 @@ Return ONLY the JSON array.`, title: 'Verbosity', type: 'combobox', placeholder: 'Type or select verbosity...', - options: [ - { label: 'auto', id: 'auto' }, - { label: 'low', id: 'low' }, - { label: 'medium', id: 'medium' }, - { label: 'high', id: 'high' }, - ], dependsOn: ['model'], - fetchOptions: async (blockId: string) => { + options: (params) => { const autoOption = { label: 'auto', id: 'auto' } - - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (!activeWorkflowId) { - return [ - autoOption, - { label: 'low', id: 'low' }, - { label: 'medium', id: 'medium' }, - { label: 'high', id: 'high' }, - ] - } - - const workflowValues = useSubBlockStore.getState().workflowValues[activeWorkflowId] - const blockValues = workflowValues?.[blockId] - const modelValue = blockValues?.model as string - - if (!modelValue) { - return [ - autoOption, - { label: 'low', id: 'low' }, - { label: 'medium', id: 'medium' }, - { label: 'high', id: 'high' }, - ] - } - + const fallback = [ + autoOption, + { label: 'low', id: 'low' }, + { label: 'medium', id: 'medium' }, + { label: 'high', id: 'high' }, + ] + const modelValue = params?.values.model + if (typeof modelValue !== 'string' || !modelValue) return fallback const validOptions = getVerbosityValuesForModel(modelValue) - if (!validOptions) { - return [ - autoOption, - { label: 'low', id: 'low' }, - { label: 'medium', id: 'medium' }, - { label: 'high', id: 'high' }, - ] - } - + if (!validOptions) return fallback return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))] }, mode: 'advanced', @@ -282,36 +220,14 @@ Return ONLY the JSON array.`, title: 'Thinking Level', type: 'combobox', placeholder: 'Type or select thinking level...', - options: [ - { label: 'none', id: 'none' }, - { label: 'minimal', id: 'minimal' }, - { label: 'low', id: 'low' }, - { label: 'medium', id: 'medium' }, - { label: 'high', id: 'high' }, - { label: 'max', id: 'max' }, - ], dependsOn: ['model'], - fetchOptions: async (blockId: string) => { + options: (params) => { const noneOption = { label: 'none', id: 'none' } - - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (!activeWorkflowId) { - return [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }] - } - - const workflowValues = useSubBlockStore.getState().workflowValues[activeWorkflowId] - const blockValues = workflowValues?.[blockId] - const modelValue = blockValues?.model as string - - if (!modelValue) { - return [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }] - } - + const fallback = [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }] + const modelValue = params?.values.model + if (typeof modelValue !== 'string' || !modelValue) return fallback const validOptions = getThinkingLevelsForModel(modelValue) - if (!validOptions) { - return [noneOption, { label: 'low', id: 'low' }, { label: 'high', id: 'high' }] - } - + if (!validOptions) return fallback return [noneOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))] }, mode: 'advanced', diff --git a/apps/sim/blocks/blocks/credential-group.test.ts b/apps/sim/blocks/blocks/credential-group.test.ts index 223fdb8ac55..81720dc88ee 100644 --- a/apps/sim/blocks/blocks/credential-group.test.ts +++ b/apps/sim/blocks/blocks/credential-group.test.ts @@ -5,6 +5,8 @@ import { QueryClient } from '@tanstack/react-query' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SubBlockConfig } from '@/blocks/types' import { credentialGroupKeys } from '@/hooks/queries/utils/credential-group-queries' +import { getSelectorDefinition } from '@/hooks/selectors/registry' +import type { SelectorKey } from '@/hooks/selectors/types' interface PendingRequest { resolve: (value: unknown) => void @@ -57,7 +59,8 @@ vi.mock('@/stores/workflows/workflow/store', () => ({ import { CredentialGroupBlock } from '@/blocks/blocks/credential-group' -const WORKSPACE_LIST_KEY = credentialGroupKeys.list('workspace-1') +const WORKSPACE_ID = 'workspace-1' +const WORKSPACE_LIST_KEY = credentialGroupKeys.list(WORKSPACE_ID) const GROUPS = [ { @@ -94,10 +97,13 @@ describe('credential group dynamic option resolution', () => { }) it('keeps the shared workspace credential-group list alive when one option resolution is cancelled', async () => { - const subBlock = getCredentialGroupSubBlock() - const fetchOptionById = subBlock.fetchOptionById - const fetchOptions = subBlock.fetchOptions - if (!fetchOptionById || !fetchOptions) throw new Error('credentialGroup resolvers are missing') + const definition = getSelectorDefinition( + getCredentialGroupSubBlock().selectorKey as SelectorKey + ) + const context = { workspaceId: WORKSPACE_ID } + const fetchOptionById = (_b: string, id: string, signal?: AbortSignal) => + definition.fetchById?.({ key: definition.key, context, detailId: id, signal }) + const fetchOptions = () => definition.fetchList?.({ key: definition.key, context }) const controller = new AbortController() const optionResolution = Promise.resolve( @@ -106,7 +112,7 @@ describe('credential group dynamic option resolution', () => { await waitForRequestCount(1) - const sharedListConsumer = fetchOptions('block-1') + const sharedListConsumer = fetchOptions() controller.abort() await Promise.resolve() @@ -122,9 +128,16 @@ describe('credential group dynamic option resolution', () => { }) it('still cancels the underlying request when React Query cancels the shared list query', async () => { - const subBlock = getCredentialGroupSubBlock() - const fetchOptionById = subBlock.fetchOptionById - if (!fetchOptionById) throw new Error('credentialGroup fetchOptionById is missing') + const definition = getSelectorDefinition( + getCredentialGroupSubBlock().selectorKey as SelectorKey + ) + const fetchOptionById = (_b: string, id: string, signal?: AbortSignal) => + definition.fetchById?.({ + key: definition.key, + context: { workspaceId: WORKSPACE_ID }, + detailId: id, + signal, + }) const controller = new AbortController() const optionResolution = Promise.resolve( diff --git a/apps/sim/blocks/blocks/credential-group.ts b/apps/sim/blocks/blocks/credential-group.ts index 4c922972550..c661c0527f1 100644 --- a/apps/sim/blocks/blocks/credential-group.ts +++ b/apps/sim/blocks/blocks/credential-group.ts @@ -1,5 +1,4 @@ import { GridOffset } from '@sim/emcn/icons' -import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' import { type CanonicalGroup, resolveActiveCanonicalValue, @@ -163,23 +162,11 @@ export const CredentialGroupBlock: BlockConfig = { id: 'credentialGroup', title: 'Credential Group', type: 'dropdown', - options: [], + selectorKey: 'workspace.credentialGroups', required: { field: 'operation', value: [...GROUP_OPERATIONS] }, mode: 'basic', canonicalParamId: 'credentialGroupId', condition: { field: 'operation', value: [...GROUP_OPERATIONS] }, - fetchOptions: async () => { - const groups = await fetchCachedCredentialGroups() - return groups - .filter((group) => group.status === 'active') - .map((group) => ({ label: group.name, id: group.id })) - .sort((a, b) => a.label.localeCompare(b.label)) - }, - fetchOptionById: async (_blockId: string, optionId: string) => { - const groups = await fetchCachedCredentialGroups() - const group = groups.find((candidate) => candidate.id === optionId) - return group ? { label: group.name, id: group.id } : null - }, }, { id: 'manualCredentialGroup', @@ -203,44 +190,14 @@ export const CredentialGroupBlock: BlockConfig = { id: 'providerFilter', title: 'Provider', type: 'dropdown', + selectorKey: 'workspace.credentialGroupProviders', multiSelect: true, emptyIsValid: true, - options: [], required: false, mode: 'basic', canonicalParamId: 'credentialProviderIds', dependsOn: ['credentialGroupId'], condition: { field: 'operation', value: 'list_credentials' }, - fetchOptions: async (blockId: string) => { - const credentialGroupId = resolveCredentialGroupIdForBlock(blockId) - if (!credentialGroupId) return [] - const groups = await fetchCachedCredentialGroups() - const group = groups.find((candidate) => candidate.id === credentialGroupId) - if (!group) return [] - return group.options - .filter((option) => option.status === 'active') - .map((option) => { - const service = getCredentialGroupProviderService(option.provider) - return { id: service.providerId, label: service.name } - }) - .sort((a, b) => a.label.localeCompare(b.label)) - }, - fetchOptionById: async (blockId: string, optionId: string) => { - const credentialGroupId = resolveCredentialGroupIdForBlock(blockId) - if (!credentialGroupId) return null - const groups = await fetchCachedCredentialGroups() - const group = groups.find((candidate) => candidate.id === credentialGroupId) - const option = group?.options.find( - (candidate) => - candidate.status === 'active' && - getCredentialGroupProviderService(candidate.provider).providerId === optionId - ) - if (!option) return null - return { - id: optionId, - label: getCredentialGroupProviderService(option.provider).name, - } - }, }, { id: 'manualProviderIds', diff --git a/apps/sim/blocks/blocks/credential.ts b/apps/sim/blocks/blocks/credential.ts index d1e7baab8f4..1ef89687e18 100644 --- a/apps/sim/blocks/blocks/credential.ts +++ b/apps/sim/blocks/blocks/credential.ts @@ -1,13 +1,5 @@ import { CredentialIcon } from '@/components/icons' -import { getServiceConfigByProviderId } from '@/lib/oauth/utils' -import { getQueryClient } from '@/app/_shell/providers/get-query-client' import type { BlockConfig } from '@/blocks/types' -import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' -import { - fetchWorkspaceCredentialList, - WORKSPACE_CREDENTIAL_LIST_STALE_TIME, -} from '@/hooks/queries/utils/fetch-workspace-credentials' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface CredentialBlockOutput { success: boolean @@ -65,37 +57,9 @@ export const CredentialBlock: BlockConfig = { id: 'providerFilter', title: 'Provider', type: 'dropdown', + selectorKey: 'workspace.credentialProviders', multiSelect: true, - options: [], condition: { field: 'operation', value: 'list' }, - fetchOptions: async () => { - const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId - if (!workspaceId) return [] - - const credentials = await getQueryClient().fetchQuery({ - queryKey: workspaceCredentialKeys.list(workspaceId), - queryFn: () => fetchWorkspaceCredentialList(workspaceId), - staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, - }) - - const seen = new Set() - const options: Array<{ label: string; id: string }> = [] - - for (const cred of credentials) { - if (cred.type === 'oauth' && cred.providerId && !seen.has(cred.providerId)) { - seen.add(cred.providerId) - const serviceConfig = getServiceConfigByProviderId(cred.providerId) - options.push({ label: serviceConfig?.name ?? cred.providerId, id: cred.providerId }) - } - } - - return options.sort((a, b) => a.label.localeCompare(b.label)) - }, - fetchOptionById: async (_blockId: string, optionId: string) => { - const serviceConfig = getServiceConfigByProviderId(optionId) - const label = serviceConfig?.name ?? optionId - return { label, id: optionId } - }, }, { id: 'credential', diff --git a/apps/sim/blocks/blocks/embeddings.test.ts b/apps/sim/blocks/blocks/embeddings.test.ts index c72eada311e..75e6f0c58de 100644 --- a/apps/sim/blocks/blocks/embeddings.test.ts +++ b/apps/sim/blocks/blocks/embeddings.test.ts @@ -2,6 +2,8 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getSelectorDefinition } from '@/hooks/selectors/registry' +import type { SelectorKey } from '@/hooks/selectors/types' const { mockFetchQuery } = vi.hoisted(() => ({ mockFetchQuery: vi.fn(), @@ -157,8 +159,9 @@ describe('Embeddings block', () => { (subBlock) => conditionProvider(subBlock) === 'openrouter' ) expect(openRouterModels?.type).toBe('combobox') - expect(openRouterModels?.options).toEqual([]) - expect(optionIds(await openRouterModels?.fetchOptions?.('block-1'))).toEqual(OPENROUTER_MODELS) + const definition = getSelectorDefinition(openRouterModels?.selectorKey as SelectorKey) + const options = await definition.fetchList?.({ key: definition.key, context: {} }) + expect(optionIds(options)).toEqual(OPENROUTER_MODELS) expect(mockFetchQuery).toHaveBeenCalledOnce() expect( diff --git a/apps/sim/blocks/blocks/embeddings.ts b/apps/sim/blocks/blocks/embeddings.ts index 3755d9412ce..8ab60bbe859 100644 --- a/apps/sim/blocks/blocks/embeddings.ts +++ b/apps/sim/blocks/blocks/embeddings.ts @@ -16,23 +16,14 @@ import { normalizeOpenRouterEmbeddingModelId, } from '@/lib/embeddings/openrouter-models' import type { EmbeddingTaskType } from '@/lib/embeddings/types' -import { getQueryClient } from '@/app/_shell/providers/get-query-client' import type { BlockConfig, BlockMeta, SubBlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' -import { providerModelsQueryOptions } from '@/hooks/queries/providers' import type { EmbeddingsResponse } from '@/tools/embeddings/types' export const EMBEDDING_BLOCK_PROVIDERS = [...EMBEDDING_CATALOG_PROVIDERS, 'openrouter'] as const type EmbeddingBlockProvider = (typeof EMBEDDING_BLOCK_PROVIDERS)[number] -async function fetchOpenRouterEmbeddingModelOptions() { - const { models } = await getQueryClient().fetchQuery( - providerModelsQueryOptions('openrouter-embeddings') - ) - return models.map((model) => ({ label: model, id: model })) -} - const TOOL_ID_BY_PROVIDER: Record = { openai: 'embeddings_openai', openrouter: 'embeddings_openrouter', @@ -81,8 +72,7 @@ MODEL_SUB_BLOCKS.push({ id: 'model', title: 'Model', type: 'combobox', - options: [], - fetchOptions: fetchOpenRouterEmbeddingModelOptions, + selectorKey: 'providers.openrouterEmbeddingModels', value: () => DEFAULT_OPENROUTER_EMBEDDING_MODEL, condition: { field: 'provider', value: 'openrouter' }, dependsOn: ['provider'], diff --git a/apps/sim/blocks/blocks/function.ts b/apps/sim/blocks/blocks/function.ts index e81e258e915..930d7c233dd 100644 --- a/apps/sim/blocks/blocks/function.ts +++ b/apps/sim/blocks/blocks/function.ts @@ -1,11 +1,6 @@ import { CodeIcon } from '@/components/icons' import { isSandboxesEnabled } from '@/lib/core/config/env-flags' import { CodeLanguage, getLanguageDisplayName } from '@/lib/execution/languages' -import { - fetchWorkspaceSandboxOption, - fetchWorkspaceSandboxOptions, - fetchWorkspaceSecretNameOptions, -} from '@/lib/workflows/subblocks/options' import type { BlockConfig } from '@/blocks/types' import type { CodeExecutionOutput } from '@/tools/function/types' @@ -107,6 +102,7 @@ try { id: 'sandboxId', title: 'Sandbox', type: 'combobox', + selectorKey: 'workspace.sandboxes', mode: 'advanced', searchable: true, // Empty means the default image — the picker must never auto-select for us. @@ -120,9 +116,6 @@ try { placeholder: 'Default image', description: 'Sim sandbox dependencies, system packages, and managed CLIs available to this block. Shell can use Sim sandboxes from either language. Manage them in Settings > Sandboxes. Leaving this empty runs on the default image.', - options: [], - fetchOptions: (blockId) => fetchWorkspaceSandboxOptions(blockId), - fetchOptionById: (blockId, optionId) => fetchWorkspaceSandboxOption(blockId, optionId), }, { id: 'secretScope', @@ -143,6 +136,7 @@ try { id: 'mountedSecrets', title: 'Secrets', type: 'dropdown', + selectorKey: 'workspace.secretNames', context: 'tool-input', paramVisibility: 'user-only', multiSelect: true, @@ -152,10 +146,8 @@ try { * so the picker must preserve the displayed casing. */ preserveLabelCase: true, - options: [], condition: { field: 'secretScope', value: 'selected' }, placeholder: 'Select secrets this tool can read', - fetchOptions: () => fetchWorkspaceSecretNameOptions(), }, ], tools: { diff --git a/apps/sim/blocks/blocks/logs.ts b/apps/sim/blocks/blocks/logs.ts index a01d3535ec0..d42182fe6ec 100644 --- a/apps/sim/blocks/blocks/logs.ts +++ b/apps/sim/blocks/blocks/logs.ts @@ -1,8 +1,4 @@ import { Library } from '@sim/emcn/icons' -import { - fetchTriggerTypeOptions, - fetchWorkspaceWorkflowOptions, -} from '@/lib/workflows/subblocks/options' import type { BlockConfig } from '@/blocks/types' export const LogsBlock: BlockConfig = { @@ -371,14 +367,13 @@ export const LogsV2Block: BlockConfig = { id: 'workflowSelector', title: 'Workflows', type: 'dropdown', + selectorKey: 'sim.workflows', multiSelect: true, - options: [], placeholder: 'All workflows', description: 'Only include runs of these workflows. Leave empty for all.', mode: 'basic', canonicalParamId: 'workflowIds', condition: { field: 'operation', value: 'query' }, - fetchOptions: () => fetchWorkspaceWorkflowOptions(), }, { id: 'manualWorkflowIds', @@ -408,14 +403,13 @@ export const LogsV2Block: BlockConfig = { id: 'triggerSelector', title: 'Triggers', type: 'dropdown', + selectorKey: 'workspace.triggerTypes', multiSelect: true, - options: [], placeholder: 'All triggers', description: 'Only include runs started this way. Leave empty for all.', mode: 'basic', canonicalParamId: 'triggers', condition: { field: 'operation', value: 'query' }, - fetchOptions: () => fetchTriggerTypeOptions(), }, { id: 'manualTriggers', diff --git a/apps/sim/blocks/blocks/mothership.ts b/apps/sim/blocks/blocks/mothership.ts index c6da307ce2c..6dfda04d027 100644 --- a/apps/sim/blocks/blocks/mothership.ts +++ b/apps/sim/blocks/blocks/mothership.ts @@ -1,5 +1,4 @@ import { Blimp } from '@sim/emcn' -import { fetchWorkspaceRawSecretNameOptions } from '@/lib/workflows/subblocks/options' import type { BlockConfig } from '@/blocks/types' import type { ToolResponse } from '@/tools/types' @@ -99,14 +98,13 @@ export const MothershipBlock: BlockConfig = { id: 'mountedSecrets', title: 'Secrets', type: 'dropdown', + selectorKey: 'workspace.rawSecretNames', mode: 'advanced', hideFromCopilot: true, multiSelect: true, searchable: true, preserveLabelCase: true, - options: [], condition: { field: 'secretScope', value: 'selected' }, - fetchOptions: () => fetchWorkspaceRawSecretNameOptions(), }, ], tools: { diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 6dc3c87d3ae..a322213781e 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -329,7 +329,15 @@ export interface SubBlockConfig { defaultChecked?: boolean description?: string }[] - | (() => { + /** + * Options DERIVED from the block's own values — no I/O. Receives the block's current + * sub-block values so a list can narrow to a sibling's selection (the reasoning efforts a + * chosen model actually supports). A remote list is never expressed here: it belongs to a + * registered selector via `selectorKey`, which works off-canvas too. + * + * Existing zero-argument option functions keep working unchanged. + */ + | ((params?: { values: Record }) => { label: string id: string icon?: React.ComponentType<{ className?: string }> @@ -439,6 +447,14 @@ export interface SubBlockConfig { allowServiceAccounts?: boolean // Selector properties — declarative mapping to a SelectorKey selectorKey?: SelectorKey + /** + * Drop the workflow this block lives in from a `sim.workflows` list. + * + * A declared flag rather than a blanket rule, because "can this reference itself" differs by + * field: the Sim trigger never receives events about its own workflow, while the Logs block + * legitimately reads the logs of the workflow it runs in. + */ + selectorExcludeSelf?: boolean selectorAllowSearch?: boolean // File selector specific properties mimeType?: string @@ -490,16 +506,6 @@ export interface SubBlockConfig { dependsOn?: string[] | { all?: string[]; any?: string[] } // Copyable-text specific: Use webhook URL from webhook management hook useWebhookUrl?: boolean - // Dropdown/Combobox: Function to fetch options dynamically - // Works with both 'dropdown' (select-only) and 'combobox' (editable with expression support) - fetchOptions?: (blockId: string) => Promise> - // Dropdown/Combobox: Function to fetch a single option's label by ID (for hydration) - // Called when component mounts with a stored value to display the correct label before options load - fetchOptionById?: ( - blockId: string, - optionId: string, - signal?: AbortSignal - ) => Promise<{ label: string; id: string } | null> /** * tool-input only: tool categories the consuming block cannot execute. They * stay visible in the picker but are greyed out with a tooltip rather than diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx b/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx index 6fe376384ba..ca5b648ab29 100644 --- a/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx +++ b/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx @@ -5,8 +5,25 @@ import { act } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSelectorDefinition } = vi.hoisted(() => ({ + mockGetSelectorDefinition: vi.fn(), +})) + +vi.mock('@/hooks/selectors/registry', () => ({ + getSelectorDefinition: mockGetSelectorDefinition, +})) + import type { SubBlockConfig } from '@/blocks/types' import { useDynamicSubBlockOptionDisplayName } from '@/hooks/queries/dynamic-subblock-options' +import type { SelectorDefinition, SelectorKey } from '@/hooks/selectors/types' + +/** Any registered key; the hook only uses it to look the definition up. */ +const SELECTOR_KEY = 'workspace.credentialGroups' as SelectorKey + +function mockDefinition(definition: Partial) { + mockGetSelectorDefinition.mockReturnValue(definition as SelectorDefinition) +} interface HookHarness { result: () => T @@ -54,16 +71,16 @@ describe('useDynamicSubBlockOptionDisplayName', () => { }) it('hydrates a stored dynamic dropdown id to its label', async () => { - const fetchOptionById = vi.fn(async (_blockId: string, optionId: string) => ({ - id: optionId, + const fetchById = vi.fn(async ({ detailId }: { detailId?: string }) => ({ + id: detailId as string, label: 'Customer support accounts', })) + mockDefinition({ key: SELECTOR_KEY, getQueryKey: () => [SELECTOR_KEY], fetchById }) const subBlock = { id: 'credentialGroup', title: 'Credential Group', type: 'dropdown', - options: [], - fetchOptionById, + selectorKey: SELECTOR_KEY, } satisfies SubBlockConfig const hook = renderHookWithClient(() => @@ -78,21 +95,23 @@ describe('useDynamicSubBlockOptionDisplayName', () => { await waitForResult(() => expect(hook.result()).toBe('Customer support accounts')) - expect(fetchOptionById).toHaveBeenCalledWith('block-1', 'group-uuid', expect.any(AbortSignal)) + expect(fetchById).toHaveBeenCalledWith( + expect.objectContaining({ detailId: 'group-uuid', context: { workspaceId: 'workspace-1' } }) + ) }) it('summarizes every selected dynamic option without dropping ids', async () => { - const fetchOptionById = vi.fn(async (_blockId: string, optionId: string) => ({ - id: optionId, - label: optionId === 'gmail' ? 'Gmail' : 'Slack', + const fetchById = vi.fn(async ({ detailId }: { detailId?: string }) => ({ + id: detailId as string, + label: detailId === 'gmail' ? 'Gmail' : 'Slack', })) + mockDefinition({ key: SELECTOR_KEY, getQueryKey: () => [SELECTOR_KEY], fetchById }) const subBlock = { id: 'providerFilter', title: 'Provider', type: 'dropdown', - options: [], multiSelect: true, - fetchOptionById, + selectorKey: SELECTOR_KEY, } satisfies SubBlockConfig const hook = renderHookWithClient(() => diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.ts b/apps/sim/hooks/queries/dynamic-subblock-options.ts index 16f50005a8c..ff49ee87888 100644 --- a/apps/sim/hooks/queries/dynamic-subblock-options.ts +++ b/apps/sim/hooks/queries/dynamic-subblock-options.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { useQueries } from '@tanstack/react-query' import { summarizeNames } from '@/lib/workflows/subblocks/display' import type { SubBlockConfig } from '@/blocks/types' +import { getSelectorDefinition } from '@/hooks/selectors/registry' export const DYNAMIC_SUBBLOCK_OPTION_STALE_TIME = 30 * 1000 @@ -44,18 +45,26 @@ export function useDynamicSubBlockOptionDisplayName({ value, }: UseDynamicSubBlockOptionDisplayNameArgs): string | null { const optionIds = useMemo(() => getResolvableOptionIds(value), [value]) - const fetchOptionById = subBlock?.fetchOptionById - const canResolve = Boolean(blockId && fetchOptionById && optionIds.length > 0) + // Label resolution follows the option source: a selector's own `fetchById`. There is no + // per-block resolver any more, so a selector without one simply renders the raw id. + const definition = subBlock?.selectorKey ? getSelectorDefinition(subBlock.selectorKey) : undefined + const fetchById = definition?.fetchById + const canResolve = Boolean(blockId && fetchById && optionIds.length > 0) const queries = useQueries({ queries: canResolve ? optionIds.map((optionId) => ({ queryKey: dynamicSubBlockOptionKeys.detail(workspaceId, blockId, subBlock?.id, optionId), queryFn: ({ signal }) => { - if (!blockId || !fetchOptionById) { + if (!blockId || !fetchById || !definition) { throw new Error('Dynamic subblock option resolver is required') } - return fetchOptionById(blockId, optionId, signal) + return fetchById({ + key: definition.key, + context: { workspaceId }, + detailId: optionId, + signal, + }) }, staleTime: DYNAMIC_SUBBLOCK_OPTION_STALE_TIME, })) diff --git a/apps/sim/hooks/selectors/providers/imap/selectors.ts b/apps/sim/hooks/selectors/providers/imap/selectors.ts new file mode 100644 index 00000000000..8d706121c48 --- /dev/null +++ b/apps/sim/hooks/selectors/providers/imap/selectors.ts @@ -0,0 +1,51 @@ +import { requestJson } from '@/lib/api/client/request' +import { imapMailboxesContract } from '@/lib/api/contracts/tools/imap' +import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared' +import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types' + +export const imapSelectors = { + /** + * Mailboxes on a self-described IMAP server. Unlike every other selector here the account is + * not a stored credential the server can resolve by id — the user types the connection in, + * so the parameters travel on the context. + * + * **The password is deliberately absent from the query key.** A query key identifies a + * resource; a credential authorizes access to it. `oauthCredential` is safe in a key because + * it is only an id, but a typed password is a secret, and React Query keys are held in cache + * and surfaced by devtools. Host, port, TLS and username already identify the mailbox list + * uniquely — the password only proves the caller may read it, and it rides the request body + * exactly as it did before. + * + * The consequence is intentional: correcting a wrong password re-runs the request (the + * previous attempt failed and cached nothing), while changing ONLY the password on an + * otherwise identical connection reuses the cached list, which is the same list. + */ + 'imap.mailboxes': { + key: 'imap.mailboxes', + contracts: [imapMailboxesContract], + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + 'imap.mailboxes', + context.host ?? 'none', + context.port ?? 'default', + context.secure ?? 'default', + context.username ?? 'none', + ], + enabled: ({ context }) => Boolean(context.host && context.username && context.password), + fetchList: async ({ context, signal }: SelectorQueryArgs) => { + if (!context.host || !context.username || !context.password) return [] + const data = await requestJson(imapMailboxesContract, { + body: { + host: context.host, + port: context.port, + secure: context.secure, + username: context.username, + password: context.password, + }, + signal, + }) + return data.mailboxes.map((mailbox) => ({ id: mailbox.path, label: mailbox.name })) + }, + }, +} satisfies Partial> diff --git a/apps/sim/hooks/selectors/providers/workspace/selectors.ts b/apps/sim/hooks/selectors/providers/workspace/selectors.ts new file mode 100644 index 00000000000..8c18ee9bef2 --- /dev/null +++ b/apps/sim/hooks/selectors/providers/workspace/selectors.ts @@ -0,0 +1,237 @@ +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' +import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' +import { fetchWorkspaceEnvironment } from '@/lib/environment/api' +import { getServiceConfigByProviderId } from '@/lib/oauth/utils' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { environmentKeys, WORKSPACE_ENVIRONMENT_STALE_TIME } from '@/hooks/queries/environment' +import { getSandboxListQueryOptions } from '@/hooks/queries/sandboxes' +import { + CREDENTIAL_GROUP_LIST_STALE_TIME, + credentialGroupKeys, + fetchCredentialGroupList, +} from '@/hooks/queries/utils/credential-group-queries' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { + fetchWorkspaceCredentialList, + WORKSPACE_CREDENTIAL_LIST_STALE_TIME, +} from '@/hooks/queries/utils/fetch-workspace-credentials' +import { SELECTOR_STALE } from '@/hooks/selectors/providers/shared' +import type { + SelectorContext, + SelectorDefinition, + SelectorKey, + SelectorOption, + SelectorQueryArgs, +} from '@/hooks/selectors/types' + +/** + * Workspace-scoped option lists: things a block picks from its OWN workspace rather than from + * a third-party account. They are selectors for the same reason the credential-scoped ones + * are — a per-block fetcher reading the active-workspace store only works on the canvas — but + * their context key is `workspaceId` instead of `oauthCredential`. + */ + +function workspaceCredentials(workspaceId: string) { + return getQueryClient().fetchQuery({ + queryKey: workspaceCredentialKeys.list(workspaceId), + queryFn: ({ signal }: { signal?: AbortSignal }) => + fetchWorkspaceCredentialList(workspaceId, signal), + staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, + }) +} + +function credentialGroups(workspaceId: string) { + return getQueryClient().fetchQuery({ + queryKey: credentialGroupKeys.list(workspaceId), + queryFn: ({ signal }: { signal?: AbortSignal }) => + fetchCredentialGroupList(workspaceId, signal), + staleTime: CREDENTIAL_GROUP_LIST_STALE_TIME, + }) +} + +function workspaceScoped( + key: SelectorKey, + fetchList: (workspaceId: string, context: SelectorContext) => Promise, + extraKey?: (context: SelectorContext) => string +): SelectorDefinition { + return { + key, + staleTime: SELECTOR_STALE, + getQueryKey: ({ context }: SelectorQueryArgs) => [ + 'selectors', + key, + context.workspaceId ?? 'none', + ...(extraKey ? [extraKey(context)] : []), + ], + enabled: ({ context }) => Boolean(context.workspaceId), + fetchList: ({ context }: SelectorQueryArgs) => + context.workspaceId ? fetchList(context.workspaceId, context) : Promise.resolve([]), + } +} + +export const workspaceSelectors = { + /** Distinct OAuth providers the workspace holds a credential for. */ + 'workspace.credentialProviders': { + ...workspaceScoped('workspace.credentialProviders', async (workspaceId) => { + const credentials = await workspaceCredentials(workspaceId) + const seen = new Set() + const options: SelectorOption[] = [] + for (const credential of credentials) { + if (credential.type !== 'oauth' || !credential.providerId) continue + if (seen.has(credential.providerId)) continue + seen.add(credential.providerId) + const service = getServiceConfigByProviderId(credential.providerId) + options.push({ id: credential.providerId, label: service?.name ?? credential.providerId }) + } + return options.sort((a, b) => a.label.localeCompare(b.label)) + }), + // Resolves a stored provider id with no list fetch at all — the service registry is local. + fetchById: async ({ detailId }: SelectorQueryArgs) => { + if (!detailId) return null + const service = getServiceConfigByProviderId(detailId) + return { id: detailId, label: service?.name ?? detailId } + }, + }, + 'workspace.credentialGroups': { + ...workspaceScoped('workspace.credentialGroups', async (workspaceId) => { + const groups = await credentialGroups(workspaceId) + return groups + .filter((group) => group.status === 'active') + .map((group) => ({ id: group.id, label: group.name })) + .sort((a, b) => a.label.localeCompare(b.label)) + }), + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!context.workspaceId || !detailId) return null + const group = (await credentialGroups(context.workspaceId)).find( + (candidate) => candidate.id === detailId + ) + return group ? { id: group.id, label: group.name } : null + }, + }, + /** Providers represented inside ONE credential group, for its per-provider filter. */ + 'workspace.credentialGroupProviders': workspaceScoped( + 'workspace.credentialGroupProviders', + async (workspaceId, context) => { + if (!context.credentialGroupId) return [] + const group = (await credentialGroups(workspaceId)).find( + (candidate) => candidate.id === context.credentialGroupId + ) + if (!group) return [] + return group.options + .filter((option) => option.status === 'active') + .map((option) => { + const service = getCredentialGroupProviderService(option.provider) + return { id: service.providerId, label: service.name } + }) + .sort((a, b) => a.label.localeCompare(b.label)) + }, + (context) => context.credentialGroupId ?? 'none' + ), + /** + * Secret NAMES the workspace can resolve. Names only — values stay server-side and are + * injected at execution. Both halves come from the one workspace-environment response, the + * client mirror of `getEffectiveDecryptedEnv`, so this picker and the `{{VAR}}` autocomplete + * can never disagree about what exists. + */ + 'workspace.secretNames': workspaceScoped('workspace.secretNames', async (workspaceId) => { + const environment = await getQueryClient().fetchQuery({ + queryKey: environmentKeys.workspace(workspaceId), + queryFn: ({ signal }: { signal?: AbortSignal }) => + fetchWorkspaceEnvironment(workspaceId, signal), + staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME, + }) + const names = new Set([ + ...Object.keys(environment?.workspace ?? {}), + ...Object.keys(environment?.personal ?? {}), + ]) + return [...names].sort().map((name) => ({ id: name, label: name })) + }), + /** Only the secret names the current actor may mount as PLAINTEXT into Copilot code. */ + 'workspace.rawSecretNames': workspaceScoped('workspace.rawSecretNames', async (workspaceId) => { + const credentials = await workspaceCredentials(workspaceId) + return selectRawMountableSecretNames(credentials).map((name) => ({ id: name, label: name })) + }), + /** + * Sandboxes a Function block can run in, narrowed to the language its sibling selects — a + * Python block must never be offered an npm sandbox. `shell` runs anywhere. + */ + 'workspace.sandboxes': { + ...workspaceScoped( + 'workspace.sandboxes', + async (workspaceId, context) => { + const { sandboxes } = await getQueryClient().fetchQuery( + getSandboxListQueryOptions(workspaceId) + ) + const language = context.language + return sandboxes + .filter((sandbox) => !language || language === 'shell' || sandbox.language === language) + .map((sandbox) => ({ id: sandbox.id, label: sandbox.name })) + }, + (context) => context.language ?? 'any' + ), + /** + * A selection left over from before a language switch is still shown, flagged rather than + * hidden: returning `null` would drop the field to its placeholder while the value stayed + * stored and stayed fatal at execution — cleared-looking, still broken, nothing to point at. + */ + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!context.workspaceId || !detailId) return null + const { sandboxes } = await getQueryClient().fetchQuery( + getSandboxListQueryOptions(context.workspaceId) + ) + const sandbox = sandboxes.find((candidate) => candidate.id === detailId) + if (!sandbox) return null + const option = { id: sandbox.id, label: sandbox.name } + const language = context.language + if ((language === 'python' || language === 'javascript') && sandbox.language !== language) { + return { ...option, label: `${option.label} · wrong language for this block` } + } + return option + }, + }, + /** + * The trigger vocabulary the Logs page filter offers, so the Logs block and that page name a + * run's origin identically. Workspace-independent, but registered here because it is the + * Logs block's list. + * + * The registry is reached lazily: `getTriggerOptions` reads the block and trigger registries, + * and importing it eagerly from a module block definitions import would close a cycle. + * Entries sharing a label merge into one comma-joined id, because the filter is a + * comma-separated list end to end and two identical rows would be unselectable apart. + */ + 'workspace.triggerTypes': { + key: 'workspace.triggerTypes', + staleTime: SELECTOR_STALE, + getQueryKey: () => ['selectors', 'workspace.triggerTypes'], + fetchList: async () => { + const { getTriggerOptions } = await import('@/lib/logs/get-trigger-options') + const valuesByLabel = new Map() + for (const option of getTriggerOptions()) { + const values = valuesByLabel.get(option.label) + if (values) values.push(option.value) + else valuesByLabel.set(option.label, [option.value]) + } + return Array.from(valuesByLabel, ([label, values]) => ({ id: values.join(','), label })) + }, + }, +} satisfies Partial> + +/** + * The OpenRouter embedding catalog. Workspace-independent — the list is the same for everyone + * — but a selector rather than a static array because it is fetched, and a fetched list has to + * be reachable from every surface, not just the canvas. + */ +export const providerSelectors = { + 'providers.openrouterEmbeddingModels': { + key: 'providers.openrouterEmbeddingModels', + staleTime: SELECTOR_STALE, + getQueryKey: () => ['selectors', 'providers.openrouterEmbeddingModels'], + fetchList: async () => { + const { providerModelsQueryOptions } = await import('@/hooks/queries/providers') + const { models } = await getQueryClient().fetchQuery( + providerModelsQueryOptions('openrouter-embeddings') + ) + return models.map((model: string) => ({ id: model, label: model })) + }, + }, +} satisfies Partial> diff --git a/apps/sim/hooks/selectors/registry.ts b/apps/sim/hooks/selectors/registry.ts index f6635fe088c..61428ef3849 100644 --- a/apps/sim/hooks/selectors/registry.ts +++ b/apps/sim/hooks/selectors/registry.ts @@ -9,6 +9,7 @@ import { cloudwatchSelectors } from '@/hooks/selectors/providers/cloudwatch/sele import { confluenceSelectors } from '@/hooks/selectors/providers/confluence/selectors' import { googleSelectors } from '@/hooks/selectors/providers/google/selectors' import { hubspotSelectors } from '@/hooks/selectors/providers/hubspot/selectors' +import { imapSelectors } from '@/hooks/selectors/providers/imap/selectors' import { jiraSelectors } from '@/hooks/selectors/providers/jira/selectors' import { jsmSelectors } from '@/hooks/selectors/providers/jsm/selectors' import { knowledgeSelectors } from '@/hooks/selectors/providers/knowledge/selectors' @@ -26,6 +27,10 @@ import { snowflakeSelectors } from '@/hooks/selectors/providers/snowflake/select import { trelloSelectors } from '@/hooks/selectors/providers/trello/selectors' import { wealthboxSelectors } from '@/hooks/selectors/providers/wealthbox/selectors' import { webflowSelectors } from '@/hooks/selectors/providers/webflow/selectors' +import { + providerSelectors, + workspaceSelectors, +} from '@/hooks/selectors/providers/workspace/selectors' import { zohoDeskSelectors } from '@/hooks/selectors/providers/zoho-desk/selectors' import { zoomSelectors } from '@/hooks/selectors/providers/zoom/selectors' import type { @@ -47,6 +52,9 @@ export const selectorRegistry = { ...googleSelectors, ...hubspotSelectors, ...managedAgentSelectors, + ...imapSelectors, + ...workspaceSelectors, + ...providerSelectors, ...microsoftSelectors, ...notionSelectors, ...pipedriveSelectors, diff --git a/apps/sim/lib/workflows/subblocks/trigger-options-live.test.ts b/apps/sim/hooks/selectors/trigger-types-live.test.ts similarity index 73% rename from apps/sim/lib/workflows/subblocks/trigger-options-live.test.ts rename to apps/sim/hooks/selectors/trigger-types-live.test.ts index c7ae616b54d..5e6d482ad0d 100644 --- a/apps/sim/lib/workflows/subblocks/trigger-options-live.test.ts +++ b/apps/sim/hooks/selectors/trigger-types-live.test.ts @@ -2,15 +2,21 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { fetchTriggerTypeOptions } from '@/lib/workflows/subblocks/options' +import { getSelectorDefinition } from '@/hooks/selectors/registry' /** * Exercises the real block and trigger registries rather than a mock: the - * fetcher reaches them through a lazy import specifically to avoid an + * selector reaches them through a lazy import specifically to avoid an * initialization cycle, and a mocked test cannot show that the import resolves * or that the registry is populated by the time the dropdown asks for options. */ -describe('fetchTriggerTypeOptions against the real registry', () => { +const fetchTriggerTypeOptions = () => + getSelectorDefinition('workspace.triggerTypes').fetchList!({ + key: 'workspace.triggerTypes', + context: {}, + }) + +describe('workspace.triggerTypes against the real registry', () => { it('resolves the lazy import into a populated list of unique labels', async () => { const options = await fetchTriggerTypeOptions() diff --git a/apps/sim/hooks/selectors/types.ts b/apps/sim/hooks/selectors/types.ts index 830a2582ce0..7a8a202378f 100644 --- a/apps/sim/hooks/selectors/types.ts +++ b/apps/sim/hooks/selectors/types.ts @@ -85,6 +85,15 @@ export type SelectorKey = | 'monday.groups' | 'sim.workflows' | 'table.columns' + | 'workspace.credentialProviders' + | 'workspace.credentialGroups' + | 'workspace.credentialGroupProviders' + | 'workspace.secretNames' + | 'workspace.rawSecretNames' + | 'workspace.sandboxes' + | 'workspace.triggerTypes' + | 'imap.mailboxes' + | 'providers.openrouterEmbeddingModels' export interface SelectorOption { id: string @@ -149,6 +158,19 @@ export interface SelectorContext { * so an environment list is filtered to the selected mode rather than mixing them. */ environmentType?: string + /** Credential group whose per-provider filter a picker enumerates. */ + credentialGroupId?: string + /** Function block runtime (`python` | `javascript` | `shell`), scoping the sandbox list. */ + language?: string + /** + * IMAP connection parameters. `imapPassword` is a raw secret, so unlike `oauthCredential` — + * which is only an id — it must NEVER appear in a query key; see `imap.mailboxes`. + */ + host?: string + port?: string + secure?: string + username?: string + password?: string } export interface SelectorQueryArgs { diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index 09f69cbb7ec..dff3f396ebb 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -46,6 +46,13 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ 'customObjectTypeId', 'pipelineId', 'environmentType', + 'credentialGroupId', + 'language', + 'host', + 'port', + 'secure', + 'username', + 'password', ]) /** diff --git a/apps/sim/lib/workflows/subblocks/options.test.ts b/apps/sim/lib/workflows/subblocks/options.test.ts deleted file mode 100644 index ad70db442af..00000000000 --- a/apps/sim/lib/workflows/subblocks/options.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockFetchQuery, mockGetSubBlockValue, mockTriggerOptions } = vi.hoisted(() => ({ - mockFetchQuery: vi.fn(), - mockGetSubBlockValue: vi.fn(), - mockTriggerOptions: vi.fn(), -})) - -vi.mock('@/app/_shell/providers/get-query-client', () => ({ - getQueryClient: () => ({ fetchQuery: mockFetchQuery }), -})) - -vi.mock('@/hooks/queries/sandboxes', () => ({ - getSandboxListQueryOptions: (workspaceId: string) => ({ - queryKey: ['sandboxes', 'list', workspaceId], - }), -})) - -vi.mock('@/stores/workflows/registry/store', () => ({ - useWorkflowRegistry: { - getState: () => ({ hydration: { workspaceId: 'workspace-1' } }), - }, -})) - -vi.mock('@/lib/logs/get-trigger-options', () => ({ - getTriggerOptions: () => mockTriggerOptions(), -})) - -vi.mock('@/stores/workflows/subblock/store', () => ({ - useSubBlockStore: { - getState: () => ({ getValue: mockGetSubBlockValue }), - }, -})) - -import { - fetchTriggerTypeOptions, - fetchWorkspaceSandboxOption, - fetchWorkspaceSandboxOptions, -} from '@/lib/workflows/subblocks/options' - -const SANDBOXES = [ - { id: 'sandbox-python', name: 'Data tools', language: 'python' }, - { id: 'sandbox-javascript', name: 'Node tools', language: 'javascript' }, -] - -describe('workspace sandbox options', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFetchQuery.mockResolvedValue({ sandboxes: SANDBOXES }) - }) - - it('lists both dependency ecosystems by their workspace-unique names for Shell blocks', async () => { - mockGetSubBlockValue.mockReturnValue('shell') - - await expect(fetchWorkspaceSandboxOptions('block-1')).resolves.toEqual([ - { id: 'sandbox-python', label: 'Data tools' }, - { id: 'sandbox-javascript', label: 'Node tools' }, - ]) - }) - - it('keeps language-specific block lists filtered and name-only', async () => { - mockGetSubBlockValue.mockReturnValue('python') - - await expect(fetchWorkspaceSandboxOptions('block-1')).resolves.toEqual([ - { id: 'sandbox-python', label: 'Data tools' }, - ]) - }) - - it('uses workspace-unique names when the sibling language is unavailable', async () => { - mockGetSubBlockValue.mockReturnValue(undefined) - - await expect(fetchWorkspaceSandboxOptions('synthetic-block-1')).resolves.toEqual([ - { id: 'sandbox-python', label: 'Data tools' }, - { id: 'sandbox-javascript', label: 'Node tools' }, - ]) - }) - - it('hydrates a stored Shell selection by name', async () => { - mockGetSubBlockValue.mockReturnValue('shell') - - await expect(fetchWorkspaceSandboxOption('block-1', 'sandbox-python')).resolves.toEqual({ - id: 'sandbox-python', - label: 'Data tools', - }) - }) - - it('hydrates a synthetic single option by name', async () => { - mockGetSubBlockValue.mockReturnValue(undefined) - - await expect( - fetchWorkspaceSandboxOption('synthetic-block-1', 'sandbox-javascript') - ).resolves.toEqual({ - id: 'sandbox-javascript', - label: 'Node tools', - }) - }) - - it('keeps mismatched persisted selections visible for language-specific blocks', async () => { - mockGetSubBlockValue.mockReturnValue('python') - - await expect(fetchWorkspaceSandboxOption('block-1', 'sandbox-javascript')).resolves.toEqual({ - id: 'sandbox-javascript', - label: 'Node tools · wrong language for this block', - }) - }) -}) - -describe('fetchTriggerTypeOptions', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('merges values that share a label into one comma-joined option', async () => { - mockTriggerOptions.mockReturnValue([ - { value: 'api', label: 'API', color: '#2563eb' }, - { value: 'copilot', label: 'Sim agent', color: '#ec4899' }, - { value: 'mothership', label: 'Sim agent', color: '#ec4899' }, - { value: 'slack', label: 'Slack', color: '#611f69' }, - ]) - - await expect(fetchTriggerTypeOptions()).resolves.toEqual([ - { id: 'api', label: 'API' }, - { id: 'copilot,mothership', label: 'Sim agent' }, - { id: 'slack', label: 'Slack' }, - ]) - }) - - it('preserves registry order so core trigger types lead the list', async () => { - mockTriggerOptions.mockReturnValue([ - { value: 'manual', label: 'Manual', color: '#6b7280' }, - { value: 'api', label: 'API', color: '#2563eb' }, - { value: 'airtable', label: 'Airtable', color: '#181d1f' }, - ]) - - await expect(fetchTriggerTypeOptions()).resolves.toEqual([ - { id: 'manual', label: 'Manual' }, - { id: 'api', label: 'API' }, - { id: 'airtable', label: 'Airtable' }, - ]) - }) -}) diff --git a/apps/sim/lib/workflows/subblocks/options.ts b/apps/sim/lib/workflows/subblocks/options.ts deleted file mode 100644 index 3e6b3f5e954..00000000000 --- a/apps/sim/lib/workflows/subblocks/options.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { selectRawMountableSecretNames } from '@/lib/credentials/secret-mount-options' -import { fetchWorkspaceEnvironment } from '@/lib/environment/api' -import { getQueryClient } from '@/app/_shell/providers/get-query-client' -import { environmentKeys, WORKSPACE_ENVIRONMENT_STALE_TIME } from '@/hooks/queries/environment' -import { getSandboxListQueryOptions, type SandboxListResponse } from '@/hooks/queries/sandboxes' -import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' -import { - fetchWorkspaceCredentialList, - WORKSPACE_CREDENTIAL_LIST_STALE_TIME, -} from '@/hooks/queries/utils/fetch-workspace-credentials' -import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' - -interface SubBlockOption { - label: string - id: string -} - -/** - * Loads the active workspace's workflows for multi-select subblocks - * (`fetchOptions`). Set `excludeActiveWorkflow` for surfaces where selecting - * the current workflow is meaningless (e.g. the Sim trigger never receives - * events about itself). - */ -export async function fetchWorkspaceWorkflowOptions(options?: { - excludeActiveWorkflow?: boolean -}): Promise { - const registry = useWorkflowRegistry.getState() - const workspaceId = registry.hydration.workspaceId - if (!workspaceId) return [] - - const workflows = await getQueryClient().fetchQuery( - getWorkflowListQueryOptions(workspaceId, 'active') - ) - - return workflows - .filter( - (workflow) => !options?.excludeActiveWorkflow || workflow.id !== registry.activeWorkflowId - ) - .map((workflow) => ({ id: workflow.id, label: workflow.name })) -} - -/** - * Loads the active workspace's secret NAMES for the Function block's secret-scope - * picker. Names only — values stay server-side and are injected at execution, the - * same discipline the copilot's workspace context uses. - * - * Both halves come from the single workspace-environment response, which is the - * client-side mirror of `getEffectiveDecryptedEnv` — the resolver the executor - * injects from. Its `personal` slice is NOT the caller's raw personal variables: - * under credential filtering it also carries personal secrets other members have - * shared into the workspace, so reading `/api/environment` instead would hide - * names the code can genuinely resolve. This is the same pair the `{{VAR}}` - * autocomplete lists, so the picker and the editor never disagree. - * - * Values are masked to `''` for non-admin viewers; only the keys are used here. - */ -export async function fetchWorkspaceSecretNameOptions(): Promise { - const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId - if (!workspaceId) return [] - - const environment = await getQueryClient().fetchQuery({ - queryKey: environmentKeys.workspace(workspaceId), - queryFn: ({ signal }: { signal?: AbortSignal }) => - fetchWorkspaceEnvironment(workspaceId, signal), - staleTime: WORKSPACE_ENVIRONMENT_STALE_TIME, - }) - - // Workspace entries shadow personal ones at execution (`getEffectiveDecryptedEnv` - // spreads workspace last), but a name-only list just de-duplicates the union. - const names = new Set([ - ...Object.keys(environment?.workspace ?? {}), - ...Object.keys(environment?.personal ?? {}), - ]) - return [...names].sort().map((name) => ({ id: name, label: name })) -} - -/** Loads only secret names the current actor may mount as plaintext into Copilot code. */ -export async function fetchWorkspaceRawSecretNameOptions(): Promise { - const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId - if (!workspaceId) return [] - - const credentials = await getQueryClient().fetchQuery({ - queryKey: workspaceCredentialKeys.list(workspaceId), - queryFn: ({ signal }: { signal?: AbortSignal }) => - fetchWorkspaceCredentialList(workspaceId, signal), - staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, - }) - - return selectRawMountableSecretNames(credentials).map((name) => ({ id: name, label: name })) -} - -/** Sandbox names are workspace-unique, so the picker needs no language suffix. */ -function toSandboxOption(sandbox: { id: string; name: string }): SubBlockOption { - return { id: sandbox.id, label: sandbox.name } -} - -async function loadWorkspaceSandboxes(): Promise { - const workspaceId = useWorkflowRegistry.getState().hydration.workspaceId - if (!workspaceId) { - throw new Error('Workspace sandbox options are unavailable until workspace hydration completes') - } - const data = await getQueryClient().fetchQuery(getSandboxListQueryOptions(workspaceId)) - return data.sandboxes -} - -/** - * Loads the sandboxes a Function block can run in, scoped to the language its - * sibling `language` subblock selects — a Python block must never be offered an - * npm sandbox. The block re-fetches when `language` changes (`dependsOn`). - */ -export async function fetchWorkspaceSandboxOptions(blockId: string): Promise { - const language = useSubBlockStore.getState().getValue(blockId, 'language') - const sandboxes = await loadWorkspaceSandboxes() - return sandboxes - .filter((sandbox) => !language || language === 'shell' || sandbox.language === language) - .map(toSandboxOption) -} - -/** - * Hydrates a stored sandbox id to its label before the option list loads. - * - * A selection left over from before a language switch is still shown, flagged - * rather than hidden. Returning `null` here would drop the field back to its - * "Default image" placeholder while the value stayed stored - * and stayed fatal at execution — the field would read as cleared and the run - * would still fail, with nothing to point at. Labelling it is what lets the - * author see what to fix. - */ -export async function fetchWorkspaceSandboxOption( - blockId: string, - optionId: string -): Promise { - const language = useSubBlockStore.getState().getValue(blockId, 'language') - const sandboxes = await loadWorkspaceSandboxes() - const sandbox = sandboxes.find((candidate) => candidate.id === optionId) - if (!sandbox) return null - - const option = toSandboxOption(sandbox) - if ((language === 'python' || language === 'javascript') && sandbox.language !== language) { - return { ...option, label: `${option.label} · wrong language for this block` } - } - return option -} - -/** - * Loads the trigger vocabulary the Logs page filter offers — the core trigger - * types plus one entry per registered webhook provider — for the Logs block's - * trigger filter, so both surfaces name a run's origin identically. - * - * The registry is reached lazily: `getTriggerOptions` reads the block and trigger - * registries, and importing it at module scope from a module that block - * definitions themselves import would close an initialization cycle. - * - * Entries sharing a label are merged into one option whose id is the comma-joined - * set of values (`copilot,mothership` for "Sim agent"). The filter is a - * comma-separated list end to end, so a merged id selects every value behind the - * label instead of offering two identical rows. - */ -export async function fetchTriggerTypeOptions(): Promise { - const { getTriggerOptions } = await import('@/lib/logs/get-trigger-options') - - const valuesByLabel = new Map() - for (const option of getTriggerOptions()) { - const values = valuesByLabel.get(option.label) - if (values) values.push(option.value) - else valuesByLabel.set(option.label, [option.value]) - } - - return Array.from(valuesByLabel, ([label, values]) => ({ id: values.join(','), label })) -} diff --git a/apps/sim/triggers/clickup/subblocks.ts b/apps/sim/triggers/clickup/subblocks.ts index a6f3004330b..eed200f2e9b 100644 --- a/apps/sim/triggers/clickup/subblocks.ts +++ b/apps/sim/triggers/clickup/subblocks.ts @@ -1,32 +1,5 @@ -import { createLogger } from '@sim/logger' -import { requestJson } from '@/lib/api/client/request' -import { clickupWorkspacesSelectorContract } from '@/lib/api/contracts/selectors/clickup' import type { SubBlockConfig } from '@/blocks/types' import { clickupSetupInstructions } from '@/triggers/clickup/utils' -import { readSubBlockValue } from '@/triggers/editor-state' - -const logger = createLogger('ClickUpTriggerSubBlocks') - -async function fetchWorkspaceOptions( - blockId: string -): Promise> { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as string | null - if (!credentialId) { - throw new Error('No ClickUp credential selected') - } - try { - const data = await requestJson(clickupWorkspacesSelectorContract, { - body: { credential: credentialId }, - }) - return (data.workspaces ?? []).map((workspace) => ({ - id: workspace.id, - label: workspace.name, - })) - } catch (error) { - logger.error('Error fetching ClickUp workspaces:', error) - throw error - } -} /** * Builds the shared subBlocks for a ClickUp trigger: OAuth credentials, the diff --git a/apps/sim/triggers/editor-state.ts b/apps/sim/triggers/editor-state.ts deleted file mode 100644 index 0480039ec72..00000000000 --- a/apps/sim/triggers/editor-state.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Editor-state readers for trigger sub-block option resolvers. - * - * Trigger definitions are a definition layer: `@/triggers` must not reach `@/blocks` - * through a static import, because block configs spread `getTrigger(...).subBlocks` at - * module scope. A static edge makes the two barrels mutually recursive, and whichever - * one an entry point reaches first wins — enter through `@/triggers` and `getTrigger()` - * runs before `TRIGGER_REGISTRY` is initialized, throwing - * `ReferenceError: Cannot access 'TRIGGER_REGISTRY' before initialization`. - * - * The Zustand stores below sit on the far side of that edge (`subblock/store` imports - * `@/blocks`), so they are loaded with a dynamic `import()`. Dynamic imports resolve at - * call time rather than during module evaluation, so they carry no initialization-order - * obligation. Every caller is an editor-side `fetchOptions`/`fetchOptionById` resolver - * that already runs asynchronously, long after both registries are built. - * - * `scripts/check-trigger-block-cycle.ts` fails the build if a static edge reappears. - */ - -/** The value the user has entered for `subBlockId` on `blockId` in the open workflow. */ -export async function readSubBlockValue(blockId: string, subBlockId: string): Promise { - const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') - return useSubBlockStore.getState().getValue(blockId, subBlockId) -} - -/** - * Every stored sub-block value for `blockId`, for resolvers that read several fields at - * once. Returns `undefined` when the block has no stored values yet. - */ -export async function readBlockValues( - blockId: string -): Promise | undefined> { - const [{ useSubBlockStore }, { useWorkflowRegistry }] = await Promise.all([ - import('@/stores/workflows/subblock/store'), - import('@/stores/workflows/registry/store'), - ]) - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - if (!activeWorkflowId) return undefined - return useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] -} - -/** The active workspace's workflows, for trigger sub-blocks that select other workflows. */ -export async function readWorkspaceWorkflowOptions(options?: { - excludeActiveWorkflow?: boolean -}): Promise> { - const { fetchWorkspaceWorkflowOptions } = await import('@/lib/workflows/subblocks/options') - return fetchWorkspaceWorkflowOptions(options) -} - -/** The workflow and workspace the editor currently has open. */ -export async function readActiveWorkflowContext(): Promise<{ - activeWorkflowId: string | null - workspaceId: string | null -}> { - const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') - const state = useWorkflowRegistry.getState() - return { - activeWorkflowId: state.activeWorkflowId, - workspaceId: state.hydration.workspaceId, - } -} diff --git a/apps/sim/triggers/hubspot/poller.ts b/apps/sim/triggers/hubspot/poller.ts index da10f2de3e0..b30847d3512 100644 --- a/apps/sim/triggers/hubspot/poller.ts +++ b/apps/sim/triggers/hubspot/poller.ts @@ -1,39 +1,10 @@ import { createLogger } from '@sim/logger' import { HubspotIcon } from '@/components/icons' -import { requestJson } from '@/lib/api/client/request' -import { hubspotPropertiesSelectorContract } from '@/lib/api/contracts/selectors/hubspot' import { getScopesForService } from '@/lib/oauth/utils' -import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('HubSpotPollingTrigger') -/** - * Resolves the effective object type from the subblock store. `getValue` returns `null` - * for fields the user hasn't interacted with yet, so we fall back to the dropdown's - * default ('contact') — otherwise the cascading property selectors render empty on - * first render even when the dropdown visibly shows "contact". - */ -async function resolveSelectedObjectType(blockId: string): Promise { - const objectType = (await readSubBlockValue(blockId, 'objectType')) as string | null - const customId = (await readSubBlockValue(blockId, 'customObjectTypeId')) as string | null - const selected = objectType ?? 'contact' - if (selected === 'custom') { - const trimmed = customId?.trim() - return trimmed ? trimmed : null - } - return selected -} - -async function fetchHubSpotProperties(blockId: string, objectType: string) { - const credentialId = (await readSubBlockValue(blockId, 'triggerCredentials')) as string | null - if (!credentialId) throw new Error('No HubSpot credential selected') - const data = await requestJson(hubspotPropertiesSelectorContract, { - query: { credentialId, objectType }, - }) - return data.properties.map((p) => ({ id: p.id, label: p.name })) -} - export const hubspotPollingTrigger: TriggerConfig = { id: 'hubspot_poller', name: 'HubSpot CRM Trigger', diff --git a/apps/sim/triggers/imap/poller.ts b/apps/sim/triggers/imap/poller.ts index e0c6c9b5eab..2f96e2528eb 100644 --- a/apps/sim/triggers/imap/poller.ts +++ b/apps/sim/triggers/imap/poller.ts @@ -1,8 +1,5 @@ import { createLogger } from '@sim/logger' import { MailServerIcon } from '@/components/icons' -import { requestJson } from '@/lib/api/client/request' -import { imapMailboxesContract } from '@/lib/api/contracts/tools/imap' -import { readSubBlockValue } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' const logger = createLogger('ImapPollingTrigger') @@ -72,45 +69,12 @@ export const imapPollingTrigger: TriggerConfig = { title: 'Mailboxes to Monitor', canvasNoun: 'a mailbox', type: 'dropdown', + selectorKey: 'imap.mailboxes', multiSelect: true, placeholder: 'Select mailboxes to monitor', description: 'Choose which mailbox/folder(s) to monitor for new emails. Leave empty to monitor INBOX.', required: false, - options: [], - fetchOptions: async (blockId: string) => { - const [host, port, secure, username, password] = await Promise.all([ - readSubBlockValue(blockId, 'host') as Promise, - readSubBlockValue(blockId, 'port') as Promise, - readSubBlockValue(blockId, 'secure') as Promise, - readSubBlockValue(blockId, 'username') as Promise, - readSubBlockValue(blockId, 'password') as Promise, - ]) - - if (!host || !username || !password) { - throw new Error('Please enter IMAP server, username, and password first') - } - - try { - const data = await requestJson(imapMailboxesContract, { - body: { - host, - port: port ?? undefined, - secure: secure ?? undefined, - username, - password, - }, - }) - - return data.mailboxes.map((mailbox) => ({ - id: mailbox.path, - label: mailbox.name, - })) - } catch (error) { - logger.error('Error fetching IMAP mailboxes:', error) - throw error - } - }, dependsOn: ['host', 'port', 'secure', 'username', 'password'], mode: 'trigger', }, diff --git a/apps/sim/triggers/sim/workspace-event.ts b/apps/sim/triggers/sim/workspace-event.ts index a2f885b2f02..1bc9835918c 100644 --- a/apps/sim/triggers/sim/workspace-event.ts +++ b/apps/sim/triggers/sim/workspace-event.ts @@ -5,7 +5,6 @@ import { SIM_TRIGGER_PROVIDER, SIM_WORKSPACE_EVENT_TRIGGER_ID, } from '@/lib/workspace-events/constants' -import { readWorkspaceWorkflowOptions } from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' export const simWorkspaceEventTrigger: TriggerConfig = { @@ -44,14 +43,14 @@ export const simWorkspaceEventTrigger: TriggerConfig = { id: 'workflowIds', title: 'Workflows', type: 'dropdown', + selectorKey: 'sim.workflows', + selectorExcludeSelf: true, multiSelect: true, - options: [], placeholder: 'All workflows', description: 'Only fire for these workflows. Leave empty to watch every workflow.', required: false, mode: 'trigger', // A subscriber never receives events about itself, so exclude it. - fetchOptions: () => readWorkspaceWorkflowOptions({ excludeActiveWorkflow: true }), }, { id: 'consecutiveFailures', From 78075559d163b83a6fb2b7d38f21ab75ef0732b6 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 20:03:14 -0700 Subject: [PATCH 09/14] feat(workspace-forking): make every fork-clearable sub-block reconfigurable at sync time, and lint that it stays so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clearDependentsOnRemap` wipes every transitive dependent of a remapped parent, and a credential mapped between environments changes value on EVERY sync — so a dependent the sync modal could not offer was re-emptied on every push, with nowhere to set it that stuck. Setting it in the target did not survive. 36 fields were in that state. The selector migration closed most of it; this closes the rest. The collector now also emits plain text dependents (`short-input` / `long-input`), which need no selector — just somewhere to type — and the modal's no-selector branch renders them through the same control it already drew for custom-block inputs. It deliberately does NOT emit the manual half of a selector-backed canonical pair: that pair already represents the field once, and its manual member is verbatim by policy, so offering both would show one concept twice and invite writing into the inactive half. `forkDependentControl` replaces the direct `customBlockInputControl` call in the view, because `fieldType` now means two different things: a custom-block input declares a Start FIELD type (`string`, `file[]`), while every other no-selector dependent is a canvas SUB-BLOCK whose own type says it. They agreed by accident before; now they are classified separately. `check:fork-dependent-coverage` fails when a sub-block under a credential/knowledge-base/table anchor is none of: selector-backed, a canonical pair member, a preserved name-based type, or text. 656 dependents, zero uncovered, no baseline — verified to fail by seeding a regression. Picked up automatically by `check:audits` (all 30 green). Documented in `/add-block`, `/add-trigger`, and `.claude/rules/sim-integrations.md`, including the two rules the checks enforce: a secret never enters a selector's query key, and a fork-clearable dependent must be reconfigurable. Co-Authored-By: Claude Opus 5 (1M context) --- .agents/skills/add-block/SKILL.md | 31 ++++++ .agents/skills/add-trigger/SKILL.md | 31 ++++++ .claude/rules/sim-integrations.md | 1 + .../fork-sync/custom-block-input-control.ts | 22 +++- .../components/fork-sync/fork-sync-view.tsx | 17 +-- .../lib/mapping/dependent-reconfigs.test.ts | 66 ++++++++++++ .../lib/mapping/dependent-reconfigs.ts | 40 ++++++- package.json | 1 + scripts/check-fork-dependent-coverage.ts | 102 ++++++++++++++++++ 9 files changed, 299 insertions(+), 12 deletions(-) create mode 100644 scripts/check-fork-dependent-coverage.ts diff --git a/.agents/skills/add-block/SKILL.md b/.agents/skills/add-block/SKILL.md index 05412f3f29a..fb60d3c0649 100644 --- a/.agents/skills/add-block/SKILL.md +++ b/.agents/skills/add-block/SKILL.md @@ -1052,3 +1052,34 @@ After creating the block, you MUST validate it against every tool it references: 4. **Verify conditions** — each subBlock should only show for the operations that actually use it 5. **Verify `{Service}BlockMeta` is exported** with at least 7 templates, each having `icon`, `title`, `prompt`, `modules`, `category`, and `tags` 6. **If any tool outputs are still unknown**, explicitly tell the user instead of guessing block outputs + +## Option Lists: `selectorKey` or `options`, never a per-block fetcher + +A sub-block gets its choices from exactly one of two places. There is no third. + +**`selectorKey` — every remote list.** Register the list in `hooks/selectors/providers//selectors.ts`, add its key to `SelectorKey`, and point the sub-block at it. A selector is parameterized by an explicit `SelectorContext`, so the same definition serves the canvas, the workspace-fork sync modal, and anything added later. + +```ts +{ id: 'triggerCredentials', type: 'oauth-input', canonicalParamId: 'oauthCredential', mode: 'trigger' }, +{ id: 'labelIds', type: 'dropdown', multiSelect: true, + selectorKey: 'gmail.labels', dependsOn: ['triggerCredentials'], mode: 'trigger' }, +{ id: 'manualLabelIds', type: 'short-input', mode: 'trigger-advanced' }, +``` + +`canonicalParamId: 'oauthCredential'` on the credential sub-block is the line people forget. `buildSelectorContextFromBlock` keys the context on a sub-block's CANONICAL id, so without it `context.oauthCredential` is never set and the picker looks unfixable without reading the store. (A credential field is also recognised by its `oauth-input` TYPE as a fallback, so a block whose shipped param is already named something else does not have to rename it.) + +**`options` — everything else.** A static array, or a pure function of the block's own values for a list that narrows to a sibling's selection. No I/O. + +```ts +options: (params) => { + const model = params?.values.model + return typeof model === 'string' ? effortsFor(model) : DEFAULT_EFFORTS +} +``` + +**Never fetch inside `options`, and never reach into the stores from a block definition.** A fetcher that resolves its credential with `readSubBlockValue(blockId, ...)` only works on the canvas — every surface that is not the editor gets an empty list. `fetchOptions`/`fetchOptionById` were removed for exactly this reason. + +Two rules the checks enforce: + +- **A secret never enters a selector's `getQueryKey`.** A query key identifies a resource; a credential authorizes access to it. A credential *id* is fine; a typed password is not (see `imap.mailboxes`). +- **A sub-block that `dependsOn` a credential / knowledge-base / table selector must be reconfigurable at fork-sync time** — a `selectorKey`, a canonical pair whose basic member is a selector, or a `short-input`/`long-input`. `bun run check:fork-dependent-coverage` fails otherwise, because a fork sync clears those fields on every push and an unofferable one can never be set anywhere that sticks. diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md index 3175d46e1c8..bfec917d60b 100644 --- a/.agents/skills/add-trigger/SKILL.md +++ b/.agents/skills/add-trigger/SKILL.md @@ -472,6 +472,37 @@ Add to `helm/sim/values.yaml` under the existing polling cron jobs: - Cursor-based (changes API): `apps/sim/lib/webhooks/polling/google-drive.ts` - Timestamp-based: `apps/sim/lib/webhooks/polling/google-calendar.ts` +## Option Lists: `selectorKey` or `options`, never a per-block fetcher + +A sub-block gets its choices from exactly one of two places. There is no third. + +**`selectorKey` — every remote list.** Register the list in `hooks/selectors/providers//selectors.ts`, add its key to `SelectorKey`, and point the sub-block at it. A selector is parameterized by an explicit `SelectorContext`, so the same definition serves the canvas, the workspace-fork sync modal, and anything added later. + +```ts +{ id: 'triggerCredentials', type: 'oauth-input', canonicalParamId: 'oauthCredential', mode: 'trigger' }, +{ id: 'labelIds', type: 'dropdown', multiSelect: true, + selectorKey: 'gmail.labels', dependsOn: ['triggerCredentials'], mode: 'trigger' }, +{ id: 'manualLabelIds', type: 'short-input', mode: 'trigger-advanced' }, +``` + +`canonicalParamId: 'oauthCredential'` on the credential sub-block is the line people forget. `buildSelectorContextFromBlock` keys the context on a sub-block's CANONICAL id, so without it `context.oauthCredential` is never set and the picker looks unfixable without reading the store. (A credential field is also recognised by its `oauth-input` TYPE as a fallback, so a block whose shipped param is already named something else does not have to rename it.) + +**`options` — everything else.** A static array, or a pure function of the block's own values for a list that narrows to a sibling's selection. No I/O. + +```ts +options: (params) => { + const model = params?.values.model + return typeof model === 'string' ? effortsFor(model) : DEFAULT_EFFORTS +} +``` + +**Never fetch inside `options`, and never reach into the stores from a block definition.** A fetcher that resolves its credential with `readSubBlockValue(blockId, ...)` only works on the canvas — every surface that is not the editor gets an empty list. `fetchOptions`/`fetchOptionById` were removed for exactly this reason. + +Two rules the checks enforce: + +- **A secret never enters a selector's `getQueryKey`.** A query key identifies a resource; a credential authorizes access to it. A credential *id* is fine; a typed password is not (see `imap.mailboxes`). +- **A sub-block that `dependsOn` a credential / knowledge-base / table selector must be reconfigurable at fork-sync time** — a `selectorKey`, a canonical pair whose basic member is a selector, or a `short-input`/`long-input`. `bun run check:fork-dependent-coverage` fails otherwise, because a fork sync clears those fields on every push and an unofferable one can never be set anywhere that sticks. + ## Checklist ### Trigger Definition diff --git a/.claude/rules/sim-integrations.md b/.claude/rules/sim-integrations.md index 0ac54ab9194..34231a900b9 100644 --- a/.claude/rules/sim-integrations.md +++ b/.claude/rules/sim-integrations.md @@ -17,4 +17,5 @@ The full authoring instructions — tool/block/icon/trigger scaffolding, SubBloc - Type coercions (`Number()`, etc.) belong in `tools.config.params` (runs at execution, after variable resolution) — never in `tools.config.tool` (runs at serialization; coercing there destroys dynamic `` references). - `canonicalParamId` must NOT match any subblock's `id`, must be unique **block-wide** (groups are keyed by canonical id across every subblock and hold exactly one `basicId`, so two operations that each need a pair need two different canonical ids), and all subblocks in a canonical group must share the same `required` status. The `inputs` section and the params function reference canonical IDs, not raw subblock IDs — the serializer deletes the subblock IDs and republishes the active member's value under the canonical ID. - A canonical pair carries ONE concept. For files that is upload (basic) + file reference (advanced), as in Gmail attachments (`blocks/blocks/gmail.ts`). Never overload the advanced side with alternate identifiers (URL, provider asset ID) — give those their own subblocks, mark mutually exclusive sources `required: false`, and enforce "exactly one" at execution. +- A sub-block's option list is EITHER `selectorKey` (a registered selector — the only way to load a remote list, and the only one that works off the canvas) OR `options` (a static array, or a pure function of the block's own values). Never fetch from a block definition, and never read the workflow stores there. A credential sub-block needs `canonicalParamId: 'oauthCredential'` for its dependants' selectors to resolve. A secret must never appear in a selector's `getQueryKey`. `bun run check:fork-dependent-coverage` fails a `dependsOn` under a credential/KB/table anchor that the fork sync modal cannot offer. - Blocks must also set the catalog/UI metadata fields `integrationType`, `tags`, `authMode`, `docsLink`, and export a `{Service}BlockMeta` — see the `/add-block` skill's BlockMeta section for details. diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts index a94764c01d0..996bcf8e496 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts @@ -90,8 +90,24 @@ export const CUSTOM_BLOCK_UNSUPPORTED_HINT = 'Uploaded in the workflow — kept * unconfigured required field relies on. */ export function isForkSyncConfigurableField( - field: Pick + field: Pick ): boolean { - if (field.parentKind !== 'custom-block') return true - return customBlockInputControl(field.fieldType) !== 'unsupported' + return forkDependentControl(field) !== 'unsupported' +} + +/** + * The control the sync modal renders for one dependent field. + * + * `fieldType` means two different things depending on where the field came from, so the two + * are classified separately rather than by one lookup that happens to agree: + * - a **custom-block input** declares a Start FIELD type (`string`, `boolean`, `file[]`), + * which {@link customBlockInputControl} maps through the canvas's own mapping; + * - every other no-selector dependent is a canvas SUB-BLOCK, whose own type says it. + */ +export function forkDependentControl( + field: Pick +): CustomBlockInputControl | 'selector' { + if (field.selectorKey) return 'selector' + if (field.parentKind === 'custom-block') return customBlockInputControl(field.fieldType) + return field.fieldType === 'long-input' ? 'textarea' : 'input' } diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx index 2f9d7843ae6..c9d9a9a54eb 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx @@ -36,7 +36,7 @@ import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-rec import { CUSTOM_BLOCK_UNSUPPORTED_HINT, customBlockBooleanOptions, - customBlockInputControl, + forkDependentControl, } from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control' import { CustomBlockInputField } from '@/ee/workspace-forking/components/fork-sync/custom-block-input-field' import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector' @@ -221,15 +221,18 @@ function DependentSelector({ : effectiveDependentValue(f, state, parentChanged) const baselineValueFor = (f: ForkDependentReconfig) => effectiveValueIn(f, {}) const effectiveValue = (f: ForkDependentReconfig) => effectiveValueIn(f, reconfig) - if (isCustomBlockInput) { - // Not a selector: there is no parent resource to browse and no options to fetch, just the - // target block's own declared input. Renders a BARE control, like `DependentFieldSelector` - // does — the row wrapper above already draws the field's label and required marker, so a - // labelled `ChipModalField` printed the title twice. + // A dependent with no selector has no parent resource to browse and no options to fetch — + // just a value to type. That is every custom-block input, and also a plain text field under + // a remapped credential (a Jira issue type, a Notion block id), which the sync clears on + // every push and so must be re-settable here. + if (!field.selectorKey) { + // Renders a BARE control, like `DependentFieldSelector` does — the row wrapper above + // already draws the field's label and required marker, so a labelled `ChipModalField` + // printed the title twice. const setValue = (value: string) => setReconfig((current) => ({ ...current, [dependentKey(field)]: value })) const value = effectiveValue(field) - switch (customBlockInputControl(field.fieldType)) { + switch (forkDependentControl(field)) { case 'switch': return ( { expect(sheet?.context.spreadsheetId).toBe('ss-src') }) + it('offers a plain text dependent of a remapped credential', () => { + // `clearDependentsOnRemap` wipes it on EVERY sync (a credential mapped across environments + // changes value each time), so a text field the modal never offered was re-emptied on every + // push with nowhere to set it that stuck. + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { + id: 'issueType', + title: 'Issue Type', + type: 'short-input', + dependsOn: ['credential'], + }, + ]) + ) + const states = new Map([ + [ + 'wf-src', + sourceState('jira', { credential: { value: 'cred-src' }, issueType: { value: 'Bug' } }), + ], + ]) + const fields = collectForkDependentReconfigs([replaceItem], states, resolve) + expect(fields).toHaveLength(1) + expect(fields[0]).toMatchObject({ subBlockKey: 'issueType', fieldType: 'short-input' }) + expect(fields[0].selectorKey).toBeUndefined() + }) + + it('does not offer the manual half of a selector-backed canonical pair', () => { + // The pair's selector member already represents the field, and the manual member is + // verbatim by policy — offering both shows one concept twice and invites writing into the + // inactive half. + vi.mocked(getBlock).mockReturnValue( + blockWith([ + { id: 'credential', title: 'Credential', type: 'oauth-input' }, + { + id: 'projectId', + title: 'Project', + type: 'project-selector', + canonicalParamId: 'projectId', + mode: 'basic', + selectorKey: 'jira.projects', + dependsOn: ['credential'], + }, + { + id: 'manualProjectId', + title: 'Project ID', + type: 'short-input', + canonicalParamId: 'projectId', + mode: 'advanced', + dependsOn: ['credential'], + }, + ]) + ) + const states = new Map([ + [ + 'wf-src', + sourceState('jira', { credential: { value: 'cred-src' }, projectId: { value: 'PROJ' } }), + ], + ]) + const keys = collectForkDependentReconfigs([replaceItem], states, resolve).map( + (field) => field.subBlockKey + ) + expect(keys).toContain('projectId') + expect(keys).not.toContain('manualProjectId') + }) + it('uses the persisted canonical mode when building a dependent selector context', () => { vi.mocked(getBlock).mockReturnValue( blockWith([ diff --git a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts index f050018cec4..c930ff8d713 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts @@ -48,6 +48,12 @@ interface ReconfigItem { * intentionally excluded: their tool dependent has no `selectorKey` and a separate * (non-`useSelectorOptions`) stack, so it falls back to the needs-config surfacing. */ +/** + * Dependent sub-block types the modal renders as a free-text field rather than a picker. + * They carry no options to fetch, so they need no selector — just somewhere to type. + */ +const TEXT_DEPENDENT_TYPES = new Set(['short-input', 'long-input']) + const PARENT_ANCHORS: ReadonlyArray<{ subBlockType: string parentKind: ForkDependentReconfig['parentKind'] @@ -130,6 +136,22 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { const canonicalIndex = buildCanonicalIndex(config.subBlocks) const gates = createCanonicalModeGates(config.subBlocks, values, canonicalModes) const configById = new Map(config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg])) + // Text members of a canonical pair whose basic side IS a selector. The pair already + // represents the field: its selector member is offered, and the manual member is verbatim by + // policy (`clearDependentsOnRemap` never clears it), so offering it too would show the same + // concept twice and invite writing into the inactive half. + const canonicalWithSelector = new Set( + config.subBlocks + .filter((cfg) => cfg.canonicalParamId && cfg.selectorKey) + .map((cfg) => cfg.canonicalParamId) + ) + const canonicalPairMembers = new Set( + config.subBlocks + .filter( + (cfg) => cfg.id && cfg.canonicalParamId && canonicalWithSelector.has(cfg.canonicalParamId) + ) + .map((cfg) => cfg.id as string) + ) // A field could hang off two anchors (or be reachable via two paths); emit it once. const seen = new Set() @@ -167,7 +189,19 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { for (const clear of getTransitiveSubBlockDependents(config.subBlocks, [anchorCfg.id])) { const dependent = configById.get(clear.subBlockId) - if (!dependent?.id || !dependent.selectorKey) continue + // A dependent is offered when the modal can actually render a control for it: a + // registered selector, or a plain text field. Anything else is skipped and the + // fork-dependent-coverage check keeps that set empty — see + // `scripts/check-fork-dependent-coverage.ts`. + // + // Text fields matter as much as selectors here. `clearDependentsOnRemap` wipes every + // transitive dependent of a remapped parent on EVERY sync (a credential mapped across + // environments changes value each time), so a field the modal never offered was + // re-emptied on every push and could not be fixed by setting it in the target either. + if (!dependent?.id) continue + const isTextDependent = + TEXT_DEPENDENT_TYPES.has(dependent.type) && !canonicalPairMembers.has(dependent.id) + if (!dependent.selectorKey && !isTextDependent) continue // Skip fields gated off by their `condition` - a selector under a now-inactive // operation (e.g. a move-only label while the block reads) isn't in play. We do // NOT require a source value: an active selector the source left empty is still @@ -233,7 +267,9 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { targetBlockId: resolveTargetBlockId(), blockName, subBlockKey: makeSubBlockKey(dependent.id), - selectorKey: dependent.selectorKey, + ...(dependent.selectorKey + ? { selectorKey: dependent.selectorKey } + : { fieldType: dependent.type }), title: makeTitle(dependent), ...(toolName ? { toolName } : {}), ...(dependencyScope ? { dependencyScope } : {}), diff --git a/package.json b/package.json index 587ef6854bc..22b7f89995d 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "check": "turbo run format:check", "check:boundaries": "bun run scripts/check-monorepo-boundaries.ts", "check:api-validation": "bun run scripts/check-api-validation-contracts.ts --check", + "check:fork-dependent-coverage": "bun run scripts/check-fork-dependent-coverage.ts", "generate:openapi": "bun run scripts/generate-openapi.ts", "check:openapi": "bun run scripts/check-openapi.ts", "generate:cli-api": "bun run scripts/generate-v2-cli-api.ts", diff --git a/scripts/check-fork-dependent-coverage.ts b/scripts/check-fork-dependent-coverage.ts new file mode 100644 index 00000000000..0e6bafe898b --- /dev/null +++ b/scripts/check-fork-dependent-coverage.ts @@ -0,0 +1,102 @@ +/** + * Fails when a sub-block that a workspace-fork sync can invalidate has no way to be + * reconfigured at sync time. + * + * `clearDependentsOnRemap` wipes every transitive dependent of a remapped parent, and a + * credential mapped between environments changes value on EVERY sync — so a dependent the + * sync modal cannot offer is re-emptied on every push, and setting it in the target does not + * survive. That was the state of 36 fields before this check existed. + * + * A dependent is covered when it is one of: + * - `selectorKey` — a registered selector, browsable against the target's parent + * - a canonical pair member whose basic side is a selector — verbatim by policy + * - a preserved name-based type — the remap deliberately keeps it + * - `short-input` / `long-input` — the modal renders a text field + * + * There is no baseline: the set is empty today and must stay empty. Adding a `dependsOn` + * under a credential/KB/table anchor now means choosing one of the four. + */ + +import { getAllBlocks } from '../apps/sim/blocks/registry' +import { getTransitiveSubBlockDependents } from '../apps/sim/lib/workflows/subblocks/dependencies' + +/** Sub-block types a fork mapping entry is anchored on. Mirrors `PARENT_ANCHORS`. */ +const ANCHOR_TYPES = new Set(['oauth-input', 'knowledge-base-selector', 'table-selector']) + +/** Mirrors `PRESERVED_NAME_BASED_DEPENDENT_TYPES` in `remap-references.ts`. */ +const PRESERVED_TYPES = new Set(['knowledge-tag-filters', 'document-tag-entry']) + +/** Mirrors `TEXT_DEPENDENT_TYPES` in `dependent-reconfigs.ts`. */ +const TEXT_TYPES = new Set(['short-input', 'long-input']) + +interface Uncovered { + block: string + subBlock: string + type: string + anchor: string +} + +function main(): void { + const uncovered = new Map() + let covered = 0 + + for (const block of getAllBlocks()) { + const subBlocks = (block.subBlocks ?? []) as Array> + const byId = new Map(subBlocks.filter((sub) => sub.id).map((sub) => [sub.id as string, sub])) + const canonicalWithSelector = new Set( + subBlocks + .filter((sub) => sub.canonicalParamId && sub.selectorKey) + .map((sub) => sub.canonicalParamId as string) + ) + + for (const anchor of subBlocks) { + if (!ANCHOR_TYPES.has(anchor.type) || !anchor.id) continue + for (const dependent of getTransitiveSubBlockDependents(subBlocks as any, [anchor.id])) { + const config = byId.get(dependent.subBlockId) + if (!config?.id) continue + if ( + config.selectorKey || + (config.canonicalParamId && canonicalWithSelector.has(config.canonicalParamId)) || + PRESERVED_TYPES.has(config.type) || + TEXT_TYPES.has(config.type) + ) { + covered++ + continue + } + uncovered.set(`${block.type}.${config.id}`, { + block: block.type, + subBlock: config.id, + type: config.type, + anchor: anchor.id, + }) + } + } + } + + if (uncovered.size === 0) { + console.log(`Fork dependent coverage: ${covered} dependents, all reconfigurable at sync time.`) + return + } + + console.error( + `Fork dependent coverage: ${uncovered.size} sub-block(s) a fork sync clears with no way to reconfigure them.\n` + ) + for (const entry of uncovered.values()) { + console.error(` ${entry.block}.${entry.subBlock} (${entry.type}) depends on ${entry.anchor}`) + } + console.error( + [ + '', + 'Each needs one of:', + " - selectorKey: '' register the list in hooks/selectors/ (the usual answer for a picker)", + ' - a canonical pair whose basic member is a selector (the manual member is verbatim)', + ' - type short-input / long-input, which the sync modal renders as a text field', + ' - a preserved name-based type in PRESERVED_NAME_BASED_DEPENDENT_TYPES', + '', + 'Otherwise the field is wiped on every sync and cannot be set anywhere that sticks.', + ].join('\n') + ) + process.exit(1) +} + +main() From f2499f38040fbd3fd0393b4356d3c5ba40320257 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 20:17:44 -0700 Subject: [PATCH 10/14] fix(sub-blocks): stop selector-backed fields rendering undefined options, and pass derived values to ComboBox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both found by Bugbot on the migration commit; both real, both mine. A selector-backed field carries no static `options` — that is the point — but `Dropdown` and `ComboBox` still read it on first paint, before any fetch resolves, and `allOptions.map(...)` is unconditional. Every field moved to `selectorKey` (Function sandboxes, Managed Agent pickers, OpenRouter embeddings, Logs workflows, the migrated triggers) would throw on mount. The type said the prop was required, so nothing caught it: the callsites pass `config.options`, which is optional on `SubBlockConfig` and now genuinely absent. Fixed on the controls rather than by restoring `options: []` to every migrated sub-block: the absence is correct, so the component owns the default. `options` is optional on both prop types and falls back to a shared empty array, which also keeps a stable identity for the memo. `ComboBox` never got the `options({ values })` wiring `Dropdown` received, so agent's reasoning-effort, verbosity and thinking-level lists — all comboboxes — silently stayed on their generic fallback instead of narrowing to the selected model. Wired the same way, reading the block's own values from the store. `selector-backed-subblocks.test.ts` pins the invariants against the real registry: a named selector exists and can list, a selector-backed field never also declares static options, and a field whose selector is gated on context declares the `dependsOn` that rebuilds it. That last one immediately caught a third bug — `clickup.triggerWorkspaceId` had no `dependsOn`, so its list would have loaded once, empty, and never refetched once a credential was picked. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/combobox/combobox.tsx | 28 ++++++- .../components/dropdown/dropdown.tsx | 14 +++- .../blocks/selector-backed-subblocks.test.ts | 84 +++++++++++++++++++ apps/sim/triggers/clickup/subblocks.ts | 1 + 4 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 apps/sim/blocks/selector-backed-subblocks.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index a02bf4c5386..06f14637f4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -16,6 +16,7 @@ import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/ import type { SubBlockConfig } from '@/blocks/types' import type { SelectorKey } from '@/hooks/selectors/types' import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' /** @@ -27,6 +28,9 @@ const MIN_ZOOM = 0.1 const MAX_ZOOM = 1 const ZOOM_DURATION = 0 +/** Shared empty list, so a selector-backed field with no static options keeps a stable identity. */ +const EMPTY_OPTIONS: ComboBoxOption[] = [] + const CREATE_ACTION_LABEL: Record, string> = { sandbox: 'Create Sandbox', } @@ -49,8 +53,12 @@ type ComboBoxOption = * Props for the ComboBox component */ interface ComboBoxProps { - /** Available options for selection - can be static array or function that returns options */ - options: ComboBoxOption[] | (() => ComboBoxOption[]) + /** + * Static options, or a function deriving them from the block's own values. Absent on a + * selector-backed field, whose list comes from `selectorKey` instead — so this must never + * be read without a default. + */ + options?: ComboBoxOption[] | ((params?: { values: Record }) => ComboBoxOption[]) /** Default value to use when no value is set */ defaultValue?: string /** ID of the parent block */ @@ -108,15 +116,27 @@ export const ComboBox = memo(function ComboBox({ const { isModelUsable, isLoading: isPermissionLoading } = usePermissionConfig() // Evaluate static options if provided as a function + // Derived option lists read the block's own values (a model's valid reasoning efforts); + // `dependsOn` already re-renders this control when one of those siblings changes. + const activeWorkflowIdForValues = useWorkflowRegistry((state) => state.activeWorkflowId) + const blockValues = useSubBlockStore((state) => + activeWorkflowIdForValues + ? state.workflowValues[activeWorkflowIdForValues]?.[blockId] + : undefined + ) + const staticOptions = useMemo(() => { - const opts = typeof options === 'function' ? options() : options + const opts = + typeof options === 'function' + ? options({ values: blockValues ?? {} }) + : (options ?? EMPTY_OPTIONS) if (subBlockId === 'model') { return opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id)) } return opts - }, [options, subBlockId, isModelUsable]) + }, [options, blockValues, subBlockId, isModelUsable]) const { fetchedOptions, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 5ee74ba37ad..7f48eb7c89a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -21,6 +21,9 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +/** Shared empty list, so a selector-backed field with no static options keeps a stable identity. */ +const EMPTY_OPTIONS: DropdownOption[] = [] + /** Selected-value badges shown before folding the rest into a "+N" badge. */ const MAX_VISIBLE_MULTI_SELECT_BADGES = 2 @@ -42,8 +45,12 @@ type DropdownOption = * Props for the Dropdown component */ interface DropdownProps { - /** Static options array or function that returns options */ - options: DropdownOption[] | ((params?: { values: Record }) => DropdownOption[]) + /** + * Static options, or a function deriving them from the block's own values. Absent on a + * selector-backed field, whose list comes from `selectorKey` instead — so this must never + * be read without a default. + */ + options?: DropdownOption[] | ((params?: { values: Record }) => DropdownOption[]) /** Default value to select when no value is set */ defaultValue?: string /** Unique identifier for the block */ @@ -143,7 +150,8 @@ export const Dropdown = memo(function Dropdown({ activeWorkflowId ? state.workflowValues[activeWorkflowId]?.[blockId] : undefined ) const evaluatedOptions = useMemo(() => { - return typeof options === 'function' ? options({ values: blockValues ?? {} }) : options + if (typeof options === 'function') return options({ values: blockValues ?? {} }) + return options ?? EMPTY_OPTIONS }, [options, blockValues]) const { diff --git a/apps/sim/blocks/selector-backed-subblocks.test.ts b/apps/sim/blocks/selector-backed-subblocks.test.ts new file mode 100644 index 00000000000..6e145be0d3c --- /dev/null +++ b/apps/sim/blocks/selector-backed-subblocks.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.unmock('@/blocks/registry') + +import { getAllBlocks } from '@/blocks/registry' +import { getSelectorDefinition } from '@/hooks/selectors/registry' +import type { SelectorKey } from '@/hooks/selectors/types' + +/** + * Guards the two invariants a selector-backed sub-block relies on, both of which broke silently + * when `fetchOptions` was replaced by `selectorKey`. + * + * A selector-backed field carries no static `options` — that is the point — so every consumer + * has to tolerate the absence. The controls read `options` on first paint, before any fetch + * resolves, and an unguarded `.map` there takes out the whole editor for that block. + */ +describe('selector-backed sub-blocks', () => { + const selectorBacked = getAllBlocks().flatMap((block) => + ((block.subBlocks ?? []) as Array>) + .filter((sub) => sub.selectorKey) + .map((sub) => ({ block: block.type, sub })) + ) + + it('covers a meaningful number of fields', () => { + // A guard on the guard: if the registry ever stops resolving, the assertions below would + // pass vacuously over an empty list. + expect(selectorBacked.length).toBeGreaterThan(50) + }) + + it('names a selector that is actually registered and can list', () => { + for (const { block, sub } of selectorBacked) { + const definition = getSelectorDefinition(sub.selectorKey as SelectorKey) + expect( + Boolean(definition.fetchList || definition.fetchPage), + `${block}.${sub.id} points at ${sub.selectorKey}, which can neither list nor page` + ).toBe(true) + } + }) + + it('never also declares a static options array', () => { + // Two sources for one list. The controls prefer fetched options only when non-empty, so a + // leftover array would show through whenever the fetch is empty or still in flight. + for (const { block, sub } of selectorBacked) { + expect( + sub.options, + `${block}.${sub.id} declares both selectorKey and options` + ).toBeUndefined() + } + }) + + it('declares dependsOn for every context field its selector requires', () => { + // A selector gated on `enabled` returns nothing until its context is populated, and the + // context is only rebuilt when a `dependsOn` sibling changes. Without the declaration the + // list loads once, empty, and never refetches when the credential is picked. + const CONTEXT_SOURCED = new Set(['oauthCredential', 'credentialGroupId', 'tableId']) + for (const { block, sub } of selectorBacked) { + const definition = getSelectorDefinition(sub.selectorKey as SelectorKey) + if (!definition.enabled) continue + const probed = new Set() + definition.enabled({ + key: definition.key, + context: new Proxy({} as Record, { + get: (_target, property) => { + if (typeof property === 'string') probed.add(property) + return undefined + }, + }), + }) + const needsContext = [...probed].some((field) => CONTEXT_SOURCED.has(field)) + if (!needsContext) continue + const dependsOn = sub.dependsOn + const declared = Array.isArray(dependsOn) + ? dependsOn.length > 0 + : Boolean(dependsOn?.all?.length || dependsOn?.any?.length) + expect( + declared, + `${block}.${sub.id} uses ${sub.selectorKey}, which is gated on context, but declares no dependsOn` + ).toBe(true) + } + }) +}) diff --git a/apps/sim/triggers/clickup/subblocks.ts b/apps/sim/triggers/clickup/subblocks.ts index eed200f2e9b..8e097b33d82 100644 --- a/apps/sim/triggers/clickup/subblocks.ts +++ b/apps/sim/triggers/clickup/subblocks.ts @@ -25,6 +25,7 @@ export function buildClickUpTriggerSubBlocks(triggerId: string): SubBlockConfig[ title: 'Workspace', type: 'dropdown', selectorKey: 'clickup.workspaces', + dependsOn: ['triggerCredentials'], placeholder: 'Select a workspace', description: 'The ClickUp Workspace the webhook is registered in', required: true, From 924cbab928eba1f83c1ce20930685a63cebcbd68 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 20:35:55 -0700 Subject: [PATCH 11/14] fix(selectors): restore the credential-group provider label resolver, and probe getQueryKey for missing dependsOn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from a final adversarial pass over the migration, both the same class as the three the review bots caught: something a `fetchOptions` sub-block declared that its replacement selector quietly does not. `credential-group.providerFilter` had a `fetchOptionById`; `workspace.credentialGroupProviders` had no `fetchById`, so the canvas card summarising several stored provider ids lost every label. The field is multi-select, which is exactly when a label has to resolve without the full list. The `dependsOn` assertion in `selector-backed-subblocks.test.ts` only probed `enabled` against three hand-listed context fields, which is why it caught `clickup.triggerWorkspaceId` and would have missed the rest. It now probes `getQueryKey` as well — a selector's key names every context field its RESULT depends on — and derives the sub-block-sourced set from `SELECTOR_CONTEXT_FIELDS` rather than a literal. Verified by deleting a real `dependsOn`: it fails naming the field and the fields it depends on. Also checked and NOT changed: `display.ts` and the copilot dropdown validator both guard `options` before use, so stripping `options: []` does not reach them. The validator's behaviour does shift from "reject every value" (an empty `validIds` array matched nothing) to "skip validation", which is a relaxation rather than a regression. `function.sandboxId` kept its `dependsOn: ['language']`. Co-Authored-By: Claude Opus 5 (1M context) --- .../blocks/selector-backed-subblocks.test.ts | 60 ++++++++++++------- .../providers/workspace/selectors.ts | 51 +++++++++++----- 2 files changed, 75 insertions(+), 36 deletions(-) diff --git a/apps/sim/blocks/selector-backed-subblocks.test.ts b/apps/sim/blocks/selector-backed-subblocks.test.ts index 6e145be0d3c..1216c970db0 100644 --- a/apps/sim/blocks/selector-backed-subblocks.test.ts +++ b/apps/sim/blocks/selector-backed-subblocks.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest' vi.unmock('@/blocks/registry') +import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' import { getAllBlocks } from '@/blocks/registry' import { getSelectorDefinition } from '@/hooks/selectors/registry' import type { SelectorKey } from '@/hooks/selectors/types' @@ -17,6 +18,17 @@ import type { SelectorKey } from '@/hooks/selectors/types' * has to tolerate the absence. The controls read `options` on first paint, before any fetch * resolves, and an unguarded `.map` there takes out the whole editor for that block. */ +/** + * Context fields supplied by a sibling SUB-BLOCK value. `workspaceId` / `workflowId` come from + * the surface itself and `excludeWorkflowId` from a flag, so none of them can be declared as a + * `dependsOn` and none belong here. + */ +const SUB_BLOCK_SOURCED = new Set( + [...SELECTOR_CONTEXT_FIELDS].filter( + (field) => field !== 'workspaceId' && field !== 'workflowId' && field !== 'excludeWorkflowId' + ) +) + describe('selector-backed sub-blocks', () => { const selectorBacked = getAllBlocks().flatMap((block) => ((block.subBlocks ?? []) as Array>) @@ -51,33 +63,39 @@ describe('selector-backed sub-blocks', () => { } }) - it('declares dependsOn for every context field its selector requires', () => { - // A selector gated on `enabled` returns nothing until its context is populated, and the - // context is only rebuilt when a `dependsOn` sibling changes. Without the declaration the - // list loads once, empty, and never refetches when the credential is picked. - const CONTEXT_SOURCED = new Set(['oauthCredential', 'credentialGroupId', 'tableId']) + it('declares dependsOn for every sub-block-sourced context field its selector reads', () => { + // A selector's `getQueryKey` names every context field its RESULT depends on, and `enabled` + // names what it is gated on. Both are probed rather than listing fields by hand, which is + // what let `clickup.triggerWorkspaceId` ship without a `dependsOn`. + // + // The declaration is what makes the list refetch: `useFetchedOptions` resets its fetch scope + // on `dependsOn` values changing. Without it the list loads once — before the credential is + // picked, or against the old language — and never reloads. for (const { block, sub } of selectorBacked) { const definition = getSelectorDefinition(sub.selectorKey as SelectorKey) - if (!definition.enabled) continue const probed = new Set() - definition.enabled({ - key: definition.key, - context: new Proxy({} as Record, { - get: (_target, property) => { - if (typeof property === 'string') probed.add(property) - return undefined - }, - }), + const context = new Proxy({} as Record, { + get: (_target, property) => { + if (typeof property === 'string') probed.add(property) + return undefined + }, }) - const needsContext = [...probed].some((field) => CONTEXT_SOURCED.has(field)) - if (!needsContext) continue + const args = { key: definition.key, context } + definition.getQueryKey(args) + definition.enabled?.(args) + + const needed = [...probed].filter((field) => SUB_BLOCK_SOURCED.has(field)) + if (needed.length === 0) continue + const dependsOn = sub.dependsOn - const declared = Array.isArray(dependsOn) - ? dependsOn.length > 0 - : Boolean(dependsOn?.all?.length || dependsOn?.any?.length) + const declared = new Set( + Array.isArray(dependsOn) + ? dependsOn + : [...(dependsOn?.all ?? []), ...(dependsOn?.any ?? [])] + ) expect( - declared, - `${block}.${sub.id} uses ${sub.selectorKey}, which is gated on context, but declares no dependsOn` + declared.size > 0, + `${block}.${sub.id} uses ${sub.selectorKey}, whose result depends on ${needed.join(', ')}, but declares no dependsOn — its list would never refetch` ).toBe(true) } }) diff --git a/apps/sim/hooks/selectors/providers/workspace/selectors.ts b/apps/sim/hooks/selectors/providers/workspace/selectors.ts index 8c18ee9bef2..62f7314cc4a 100644 --- a/apps/sim/hooks/selectors/providers/workspace/selectors.ts +++ b/apps/sim/hooks/selectors/providers/workspace/selectors.ts @@ -109,24 +109,45 @@ export const workspaceSelectors = { }, }, /** Providers represented inside ONE credential group, for its per-provider filter. */ - 'workspace.credentialGroupProviders': workspaceScoped( - 'workspace.credentialGroupProviders', - async (workspaceId, context) => { - if (!context.credentialGroupId) return [] - const group = (await credentialGroups(workspaceId)).find( + 'workspace.credentialGroupProviders': { + ...workspaceScoped( + 'workspace.credentialGroupProviders', + async (workspaceId, context) => { + if (!context.credentialGroupId) return [] + const group = (await credentialGroups(workspaceId)).find( + (candidate) => candidate.id === context.credentialGroupId + ) + if (!group) return [] + return group.options + .filter((option) => option.status === 'active') + .map((option) => { + const service = getCredentialGroupProviderService(option.provider) + return { id: service.providerId, label: service.name } + }) + .sort((a, b) => a.label.localeCompare(b.label)) + }, + (context) => context.credentialGroupId ?? 'none' + ), + /** + * Resolves one stored provider id to its service name. The field is multi-select, so the + * canvas card summarises several stored ids at once and needs each label before (or + * without) the full list — which is what `useDynamicSubBlockOptionDisplayName` asks for. + */ + fetchById: async ({ context, detailId }: SelectorQueryArgs) => { + if (!context.workspaceId || !context.credentialGroupId || !detailId) return null + const group = (await credentialGroups(context.workspaceId)).find( (candidate) => candidate.id === context.credentialGroupId ) - if (!group) return [] - return group.options - .filter((option) => option.status === 'active') - .map((option) => { - const service = getCredentialGroupProviderService(option.provider) - return { id: service.providerId, label: service.name } - }) - .sort((a, b) => a.label.localeCompare(b.label)) + const option = group?.options.find( + (candidate) => + candidate.status === 'active' && + getCredentialGroupProviderService(candidate.provider).providerId === detailId + ) + if (!option) return null + const service = getCredentialGroupProviderService(option.provider) + return { id: service.providerId, label: service.name } }, - (context) => context.credentialGroupId ?? 'none' - ), + }, /** * Secret NAMES the workspace can resolve. Names only — values stay server-side and are * injected at execution. Both halves come from the one workspace-environment response, the From d702e598cacee4c0295e1a6c070cdcf472567b23 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 21:10:51 -0700 Subject: [PATCH 12/14] chore: re-record the page-graph baseline after staging's growth consumed its allowance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's "Repo audits" step failed on `check:tool-registry-boundary`. Measured before touching anything, because the reported growth (+32 and +42 modules on two routes) looked like this branch had dragged the selector registry somewhere new. It had not. Recording a baseline on clean `origin/staging` and diffing against this branch attributes the growth precisely: this branch: +1 to +4 modules per route, +35 total across 25 routes staging: the rest Staging's six merged commits landed both failing routes at exactly their tolerance — knowledge/[id] at +31 of an allowed +31, layout at +41 of +41 — so `check:tool-registry-boundary` passed there with nothing left over. This branch's +1 tipped both past the line. The next PR to touch anything would have tripped it just the same, whatever it contained. The +1..+4 is the selector consolidation's real cost: `selectorRegistry` is one static object, so a page reaching any selector reaches every provider, and this branch adds four (hubspot, managed-agent, imap, workspace). That is the same cost the 27 existing providers already impose, and it is what buys one option-list mechanism that works off the canvas. Also tried deferring the workspace provider's data-layer imports to fetch time. Reverted: this checker follows dynamic imports, so the numbers did not move, leaving only a Promise.all-of-imports shape that reads worse than the 27 sibling providers it sits next to. Co-Authored-By: Claude Opus 5 (1M context) --- ...check-tool-registry-boundary.baseline.json | 392 +++++++++--------- 1 file changed, 196 insertions(+), 196 deletions(-) diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index a93c3db63a8..50ca5edf2ca 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -10,49 +10,49 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 2959, + "modules": 2996, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1346, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1000, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 865, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 862, - "apps/sim/triggers/registry.ts": 448, - "apps/sim/blocks/registry.ts": 312, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 304, - "apps/sim/lib/auth/index.ts": 208 + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1366, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1020, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 885, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 882, + "apps/sim/triggers/registry.ts": 452, + "apps/sim/blocks/registry.ts": 311, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 308, + "apps/sim/lib/auth/index.ts": 209 } }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 1968, + "modules": 1990, "gateways": { - "apps/sim/triggers/registry.ts": 448, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 293, - "apps/sim/lib/auth/index.ts": 214, + "apps/sim/triggers/registry.ts": 452, + "apps/sim/blocks/registry.ts": 334, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 296, + "apps/sim/lib/auth/index.ts": 215, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 144, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 130, "apps/sim/lib/api/contracts/index.ts": 108, - "apps/sim/lib/webhooks/providers/index.ts": 102 + "apps/sim/lib/webhooks/providers/index.ts": 103 } }, "app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": { - "modules": 58, + "modules": 59, "gateways": { - "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 57, - "apps/sim/hooks/queries/workspace-files.ts": 54 + "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 58, + "apps/sim/hooks/queries/workspace-files.ts": 55 } }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 1968, + "modules": 1990, "gateways": { - "apps/sim/triggers/registry.ts": 448, - "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 295, - "apps/sim/lib/auth/index.ts": 214, + "apps/sim/triggers/registry.ts": 452, + "apps/sim/blocks/registry.ts": 334, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 298, + "apps/sim/lib/auth/index.ts": 215, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 144, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 130, "apps/sim/lib/api/contracts/index.ts": 108, - "apps/sim/lib/webhooks/providers/index.ts": 102 + "apps/sim/lib/webhooks/providers/index.ts": 103 } }, "app/workspace/[workspaceId]/home/layout.tsx": { @@ -60,119 +60,119 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 2959, + "modules": 2996, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1346, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1000, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 865, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 862, - "apps/sim/triggers/registry.ts": 448, - "apps/sim/blocks/registry.ts": 312, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 304, - "apps/sim/lib/auth/index.ts": 208 + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1366, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1020, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 885, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 882, + "apps/sim/triggers/registry.ts": 452, + "apps/sim/blocks/registry.ts": 311, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 308, + "apps/sim/lib/auth/index.ts": 209 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1300, + "modules": 1315, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1273, - "apps/sim/blocks/registry.ts": 919, - "apps/sim/triggers/index.ts": 484, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1289, + "apps/sim/blocks/registry.ts": 932, + "apps/sim/triggers/index.ts": 489, "apps/sim/lib/api/contracts/index.ts": 128, - "apps/sim/stores/workflows/registry/store.ts": 67, - "apps/sim/hooks/queries/deployments.ts": 61, - "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/lib/workflows/comparison/compare.ts": 58 + "apps/sim/blocks/blocks/credential-group.ts": 105, + "apps/sim/stores/workflows/registry/store.ts": 80, + "apps/sim/hooks/queries/deployments.ts": 73, + "apps/sim/lib/workflows/comparison/compare.ts": 70 } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1283, + "modules": 1297, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1282, - "apps/sim/triggers/registry.ts": 483, - "apps/sim/blocks/registry.ts": 342, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1296, + "apps/sim/triggers/registry.ts": 488, + "apps/sim/blocks/registry.ts": 341, "apps/sim/lib/api/contracts/index.ts": 134, - "apps/sim/stores/workflows/registry/store.ts": 64, - "apps/sim/hooks/queries/deployments.ts": 61, - "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/lib/workflows/comparison/compare.ts": 58 + "apps/sim/stores/workflows/registry/store.ts": 75, + "apps/sim/hooks/queries/deployments.ts": 72, + "apps/sim/lib/workflows/comparison/compare.ts": 69, + "apps/sim/lib/workflows/comparison/resolve-values.ts": 66 } }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1287, + "modules": 1301, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1007, - "apps/sim/blocks/registry.ts": 920, - "apps/sim/triggers/index.ts": 484, + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1017, + "apps/sim/blocks/registry.ts": 933, + "apps/sim/triggers/index.ts": 489, "apps/sim/lib/api/contracts/index.ts": 130, - "apps/sim/stores/workflows/registry/store.ts": 67, - "apps/sim/hooks/queries/deployments.ts": 61, - "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/lib/workflows/comparison/compare.ts": 58 + "apps/sim/blocks/blocks/credential-group.ts": 106, + "apps/sim/stores/workflows/registry/store.ts": 80, + "apps/sim/hooks/queries/deployments.ts": 73, + "apps/sim/lib/workflows/comparison/compare.ts": 70 } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1501, + "modules": 1521, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1218, - "apps/sim/triggers/registry.ts": 483, - "apps/sim/blocks/registry.ts": 338, - "apps/sim/blocks/registry-maps.ts": 335, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1234, + "apps/sim/triggers/registry.ts": 488, + "apps/sim/blocks/registry.ts": 337, + "apps/sim/blocks/registry-maps.ts": 334, "apps/sim/lib/api/contracts/index.ts": 120, "apps/sim/connectors/registry.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 55 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 57 } }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1504, + "modules": 1536, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1221, - "apps/sim/triggers/registry.ts": 483, - "apps/sim/blocks/registry.ts": 338, - "apps/sim/blocks/registry-maps.ts": 335, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1249, + "apps/sim/triggers/registry.ts": 488, + "apps/sim/blocks/registry.ts": 337, + "apps/sim/blocks/registry-maps.ts": 334, "apps/sim/lib/api/contracts/index.ts": 120, "apps/sim/connectors/registry.ts": 61, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 55 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 57 } }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2177, + "modules": 2203, "gateways": { - "apps/sim/triggers/registry.ts": 448, - "apps/sim/blocks/registry.ts": 333, - "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 273, - "apps/sim/lib/knowledge/application/knowledge-bases.ts": 218, - "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 182, - "apps/sim/lib/auth/index.ts": 163, + "apps/sim/triggers/registry.ts": 452, + "apps/sim/blocks/registry.ts": 332, + "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 276, + "apps/sim/lib/knowledge/application/knowledge-bases.ts": 220, + "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 186, + "apps/sim/lib/auth/index.ts": 164, "apps/sim/lib/knowledge/orchestration/index.ts": 138, "apps/sim/lib/knowledge/orchestration/connectors.ts": 133 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 2003, + "modules": 2045, "gateways": { - "apps/sim/triggers/registry.ts": 448, - "apps/sim/blocks/registry.ts": 332, - "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 279, - "apps/sim/lib/auth/index.ts": 278, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 271, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 176, - "apps/sim/lib/api/contracts/index.ts": 110, - "apps/sim/lib/webhooks/providers/index.ts": 102 + "apps/sim/triggers/registry.ts": 452, + "apps/sim/blocks/registry.ts": 333, + "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 304, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 296, + "apps/sim/lib/auth/index.ts": 280, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 203, + "apps/sim/lib/api/contracts/index.ts": 109, + "apps/sim/lib/webhooks/providers/index.ts": 103 } }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1737, + "modules": 1755, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1456, - "apps/sim/triggers/registry.ts": 483, - "apps/sim/app/workspace/[workspaceId]/logs/components/index.ts": 421, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts": 365, - "apps/sim/blocks/registry.ts": 329, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 321, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 286, + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1470, + "apps/sim/triggers/registry.ts": 488, + "apps/sim/app/workspace/[workspaceId]/logs/components/index.ts": 420, + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts": 366, + "apps/sim/blocks/registry.ts": 328, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 322, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 287, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 255 } }, @@ -185,16 +185,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2039, + "modules": 2062, "gateways": { - "apps/sim/triggers/registry.ts": 448, - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 434, - "apps/sim/blocks/registry.ts": 328, - "apps/sim/lib/auth/index.ts": 288, + "apps/sim/triggers/registry.ts": 452, + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 440, + "apps/sim/blocks/registry.ts": 332, + "apps/sim/lib/auth/index.ts": 290, "apps/sim/lib/api/contracts/index.ts": 107, - "apps/sim/lib/webhooks/providers/index.ts": 102, + "apps/sim/lib/webhooks/providers/index.ts": 103, "apps/sim/lib/api/contracts/tools/index.ts": 59, - "apps/sim/lib/uploads/utils/file-utils.server.ts": 42 + "apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx": 42 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { @@ -202,16 +202,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1610, + "modules": 1627, "gateways": { - "apps/sim/lib/auth/index.ts": 1475, - "apps/sim/triggers/index.ts": 449, - "apps/sim/blocks/registry.ts": 342, - "apps/sim/blocks/registry-maps.ts": 339, - "apps/sim/lib/api/contracts/index.ts": 123, - "apps/sim/lib/webhooks/providers/index.ts": 102, - "apps/sim/stores/workflows/registry/store.ts": 68, - "apps/sim/hooks/queries/deployments.ts": 62 + "apps/sim/lib/auth/index.ts": 1491, + "apps/sim/blocks/registry.ts": 621, + "apps/sim/blocks/registry-maps.ts": 618, + "apps/sim/triggers/index.ts": 453, + "apps/sim/blocks/blocks/credential-group.ts": 269, + "apps/sim/stores/workflows/registry/store.ts": 252, + "apps/sim/hooks/queries/deployments.ts": 244, + "apps/sim/lib/workflows/comparison/compare.ts": 239 } }, "app/workspace/[workspaceId]/settings/layout.tsx": { @@ -223,25 +223,25 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1317, + "modules": 1333, "gateways": { - "apps/sim/triggers/registry.ts": 483, - "apps/sim/blocks/registry.ts": 344, - "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/triggers/registry.ts": 488, + "apps/sim/blocks/registry.ts": 343, + "apps/sim/blocks/registry-maps.ts": 340, "apps/sim/lib/api/contracts/index.ts": 135, - "apps/sim/stores/workflows/registry/store.ts": 64, - "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 63, - "apps/sim/hooks/queries/deployments.ts": 61, - "apps/sim/lib/api/contracts/tools/index.ts": 60 + "apps/sim/stores/workflows/registry/store.ts": 75, + "apps/sim/hooks/queries/deployments.ts": 72, + "apps/sim/lib/workflows/comparison/compare.ts": 69, + "apps/sim/lib/workflows/comparison/resolve-values.ts": 66 } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1402, + "modules": 1418, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1401, - "apps/sim/triggers/registry.ts": 483, - "apps/sim/blocks/registry.ts": 343, - "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1417, + "apps/sim/triggers/registry.ts": 488, + "apps/sim/blocks/registry.ts": 342, + "apps/sim/blocks/registry-maps.ts": 340, "apps/sim/lib/api/contracts/index.ts": 126, "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86, @@ -249,12 +249,12 @@ } }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1400, + "modules": 1416, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1399, - "apps/sim/triggers/registry.ts": 483, - "apps/sim/blocks/registry.ts": 343, - "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1415, + "apps/sim/triggers/registry.ts": 488, + "apps/sim/blocks/registry.ts": 342, + "apps/sim/blocks/registry-maps.ts": 340, "apps/sim/lib/api/contracts/index.ts": 126, "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86, @@ -262,120 +262,120 @@ } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1268, + "modules": 1284, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 988, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 976, - "apps/sim/blocks/registry.ts": 964, - "apps/sim/blocks/registry-maps.ts": 962, - "apps/sim/triggers/index.ts": 484, + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1000, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 988, + "apps/sim/blocks/registry.ts": 976, + "apps/sim/blocks/registry-maps.ts": 974, + "apps/sim/triggers/index.ts": 489, "apps/sim/lib/api/contracts/index.ts": 135, - "apps/sim/stores/workflows/registry/store.ts": 74, - "apps/sim/hooks/queries/deployments.ts": 68 + "apps/sim/blocks/blocks/credential-group.ts": 119, + "apps/sim/stores/workflows/registry/store.ts": 91 } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 2236, + "modules": 2256, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 584, - "apps/sim/triggers/registry.ts": 448, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 335, - "apps/sim/blocks/registry.ts": 312, - "apps/sim/lib/auth/index.ts": 309, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 293, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 265, + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 585, + "apps/sim/triggers/registry.ts": 452, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 336, + "apps/sim/blocks/registry.ts": 311, + "apps/sim/lib/auth/index.ts": 311, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 294, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 266, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 235 } }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 1827, + "modules": 1849, "gateways": { - "apps/sim/triggers/registry.ts": 448, - "apps/sim/blocks/registry.ts": 334, - "apps/sim/lib/auth/index.ts": 299, - "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 140, + "apps/sim/triggers/registry.ts": 452, + "apps/sim/blocks/registry.ts": 333, + "apps/sim/lib/auth/index.ts": 300, + "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 143, "apps/sim/lib/api/contracts/index.ts": 112, - "apps/sim/lib/webhooks/providers/index.ts": 102, + "apps/sim/lib/webhooks/providers/index.ts": 103, "apps/sim/lib/api/contracts/tools/index.ts": 60, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 53 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 55 } }, "app/workspace/[workspaceId]/upgrade/page.tsx": { - "modules": 268, + "modules": 270, "gateways": { - "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 261, - "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 214, - "apps/sim/lib/billing/client/upgrade.ts": 206, - "apps/sim/hooks/queries/organization.ts": 202, - "apps/sim/hooks/queries/workspace.ts": 194, - "apps/sim/lib/api/contracts/index.ts": 192, + "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 263, + "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 216, + "apps/sim/lib/billing/client/upgrade.ts": 208, + "apps/sim/hooks/queries/organization.ts": 204, + "apps/sim/hooks/queries/workspace.ts": 196, + "apps/sim/lib/api/contracts/index.ts": 194, "apps/sim/lib/api/contracts/tools/index.ts": 61, "apps/sim/lib/api/contracts/v1/index.ts": 38 } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 2187, + "modules": 2226, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2186, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 545, - "apps/sim/triggers/registry.ts": 483, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 462, - "apps/sim/blocks/registry.ts": 329, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2225, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 544, + "apps/sim/triggers/registry.ts": 488, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 461, + "apps/sim/blocks/registry.ts": 328, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 289, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 145, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 138 + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 162, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 151 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2214, + "modules": 2253, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2213, - "apps/sim/triggers/registry.ts": 483, - "apps/sim/blocks/registry.ts": 329, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2252, + "apps/sim/triggers/registry.ts": 488, + "apps/sim/blocks/registry.ts": 328, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 308, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 270, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 227, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 143, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 136 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 155, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 144 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2187, + "modules": 2226, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 922, - "apps/sim/triggers/registry.ts": 483, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 462, - "apps/sim/blocks/registry.ts": 329, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 947, + "apps/sim/triggers/registry.ts": 488, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 461, + "apps/sim/blocks/registry.ts": 328, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 289, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 145, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 139, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 138 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 163, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 145 } }, "app/workspace/layout.tsx": { - "modules": 1219, + "modules": 1233, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1209, - "apps/sim/triggers/registry.ts": 483, - "apps/sim/blocks/registry.ts": 344, - "apps/sim/blocks/registry-maps.ts": 341, - "apps/sim/lib/api/contracts/index.ts": 139, - "apps/sim/stores/workflows/registry/store.ts": 67, - "apps/sim/hooks/queries/deployments.ts": 64, - "apps/sim/lib/workflows/comparison/compare.ts": 61 + "apps/sim/app/workspace/providers/socket-provider.tsx": 1223, + "apps/sim/triggers/registry.ts": 488, + "apps/sim/blocks/registry.ts": 343, + "apps/sim/blocks/registry-maps.ts": 340, + "apps/sim/stores/workflows/registry/store.ts": 271, + "apps/sim/hooks/queries/deployments.ts": 268, + "apps/sim/lib/workflows/comparison/compare.ts": 262, + "apps/sim/lib/workflows/comparison/resolve-values.ts": 259 } }, "app/workspace/page.tsx": { - "modules": 1213, + "modules": 1227, "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 981, - "apps/sim/triggers/index.ts": 484, - "apps/sim/blocks/registry.ts": 344, - "apps/sim/blocks/registry-maps.ts": 341, + "apps/sim/lib/auth/stale-session-recovery.ts": 993, + "apps/sim/triggers/index.ts": 489, + "apps/sim/blocks/registry.ts": 343, + "apps/sim/blocks/registry-maps.ts": 340, "apps/sim/lib/api/contracts/index.ts": 138, - "apps/sim/stores/workflows/registry/store.ts": 66, - "apps/sim/hooks/queries/deployments.ts": 60, - "apps/sim/lib/api/contracts/tools/index.ts": 60 + "apps/sim/stores/workflows/registry/store.ts": 81, + "apps/sim/hooks/queries/deployments.ts": 74, + "apps/sim/lib/workflows/comparison/compare.ts": 71 } } } From 1f423ab5c43c2a4b7a2fa1c4f4c37100278b9b2a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 21:20:12 -0700 Subject: [PATCH 13/14] fix(workspace-forking): actually apply text dependents, and resolve labels against the full selector context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from Bugbot; both real, both mine. **Text dependents never persisted.** `applyDependentOverrides` allowlisted `dependsOn && selectorKey`, so the plain text fields the collector started emitting were offered in the modal, stored, and gated on by the Sync button — then dropped on apply. The field stayed wiped on every push and the typed value went nowhere, which is the exact treadmill the feature existed to end. The cause was the rule being written twice. `reconfigurableDependentIds` is now the single definition of "a dependent the modal can offer AND the sync can write back", used by the collector and by the apply side. A test asserts the two agree by round-tripping through `applyDependentOverrides`, and fails against the old allowlist. **Provider labels stayed raw ids.** `useDynamicSubBlockOptionDisplayName` called `fetchById` with a `workspaceId`-only context, which silently fails any selector scoped by a sibling — `workspace.credentialGroupProviders` needs the group before it can name a provider, so the `fetchById` restored last round returned null every time. It now builds the block's real context with `buildSelectorContextFromBlock`, the same one the canvas uses. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/mapping/dependent-reconfigs.ts | 31 ++------ .../lib/remap/remap-block-type.test.ts | 79 ++++++++++++++++++- .../lib/remap/remap-references.ts | 48 ++++++++++- .../hooks/queries/dynamic-subblock-options.ts | 30 ++++++- 4 files changed, 155 insertions(+), 33 deletions(-) diff --git a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts index c930ff8d713..9d2357de99e 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts @@ -25,6 +25,7 @@ import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan' import { createCanonicalModeGates, + reconfigurableDependentIds, scanWorkflowReferences, } from '@/ee/workspace-forking/lib/remap/remap-references' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -48,12 +49,6 @@ interface ReconfigItem { * intentionally excluded: their tool dependent has no `selectorKey` and a separate * (non-`useSelectorOptions`) stack, so it falls back to the needs-config surfacing. */ -/** - * Dependent sub-block types the modal renders as a free-text field rather than a picker. - * They carry no options to fetch, so they need no selector — just somewhere to type. - */ -const TEXT_DEPENDENT_TYPES = new Set(['short-input', 'long-input']) - const PARENT_ANCHORS: ReadonlyArray<{ subBlockType: string parentKind: ForkDependentReconfig['parentKind'] @@ -136,22 +131,9 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { const canonicalIndex = buildCanonicalIndex(config.subBlocks) const gates = createCanonicalModeGates(config.subBlocks, values, canonicalModes) const configById = new Map(config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg])) - // Text members of a canonical pair whose basic side IS a selector. The pair already - // represents the field: its selector member is offered, and the manual member is verbatim by - // policy (`clearDependentsOnRemap` never clears it), so offering it too would show the same - // concept twice and invite writing into the inactive half. - const canonicalWithSelector = new Set( - config.subBlocks - .filter((cfg) => cfg.canonicalParamId && cfg.selectorKey) - .map((cfg) => cfg.canonicalParamId) - ) - const canonicalPairMembers = new Set( - config.subBlocks - .filter( - (cfg) => cfg.id && cfg.canonicalParamId && canonicalWithSelector.has(cfg.canonicalParamId) - ) - .map((cfg) => cfg.id as string) - ) + // Shared with `applyDependentOverrides`, so what the modal offers is exactly what the sync + // can write back — the two encoded this rule separately once and drifted. + const reconfigurableIds = reconfigurableDependentIds(config.subBlocks) // A field could hang off two anchors (or be reachable via two paths); emit it once. const seen = new Set() @@ -198,10 +180,7 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { // transitive dependent of a remapped parent on EVERY sync (a credential mapped across // environments changes value each time), so a field the modal never offered was // re-emptied on every push and could not be fixed by setting it in the target either. - if (!dependent?.id) continue - const isTextDependent = - TEXT_DEPENDENT_TYPES.has(dependent.type) && !canonicalPairMembers.has(dependent.id) - if (!dependent.selectorKey && !isTextDependent) continue + if (!dependent?.id || !reconfigurableIds.has(dependent.id)) continue // Skip fields gated off by their `condition` - a selector under a now-inactive // operation (e.g. a move-only label while the block reads) isn't in play. We do // NOT require a source value: an active selector the source left empty is still diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-block-type.test.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-block-type.test.ts index 293aa40cf48..1819baa515f 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-block-type.test.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-block-type.test.ts @@ -1,10 +1,13 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { getBlock } from '@/blocks/registry' import { + applyDependentOverrides, customBlockInputStorageKey, type ForkReferenceResolver, + reconfigurableDependentIds, remapForkBlockType, replaceCustomBlockInputs, scanWorkflowReferences, @@ -195,3 +198,77 @@ describe('replaceCustomBlockInputs target carry-over', () => { expect(result.workflowId).toEqual({ value: 'wf-prod' }) }) }) + +describe('reconfigurableDependentIds', () => { + const SUBS = [ + { id: 'credential', type: 'oauth-input' }, + { id: 'issueType', type: 'short-input', dependsOn: ['credential'] }, + { id: 'notes', type: 'long-input', dependsOn: ['credential'] }, + { + id: 'labelId', + type: 'file-selector', + dependsOn: ['credential'], + selectorKey: 'gmail.labels', + }, + { + id: 'projectId', + type: 'project-selector', + dependsOn: ['credential'], + selectorKey: 'jira.projects', + canonicalParamId: 'projectId', + }, + { + id: 'manualProjectId', + type: 'short-input', + dependsOn: ['credential'], + canonicalParamId: 'projectId', + }, + { id: 'watchColumns', type: 'dropdown', dependsOn: ['credential'] }, + { id: 'standalone', type: 'short-input' }, + ] + + it('offers selector-backed and plain text dependents', () => { + const allowed = reconfigurableDependentIds(SUBS) + expect([...allowed].sort()).toEqual(['issueType', 'labelId', 'notes', 'projectId']) + }) + + it('excludes the manual half of a selector-backed canonical pair', () => { + expect(reconfigurableDependentIds(SUBS).has('manualProjectId')).toBe(false) + }) + + it('excludes a dependent the modal can render no control for', () => { + // A `dropdown` with no selector has options to fetch and no way to fetch them here. + expect(reconfigurableDependentIds(SUBS).has('watchColumns')).toBe(false) + }) + + it('excludes a field that depends on nothing', () => { + expect(reconfigurableDependentIds(SUBS).has('standalone')).toBe(false) + }) + + it('is the SAME set the sync actually writes back', () => { + // The collector offers these and `applyDependentOverrides` writes them. Encoding the rule + // twice is what let text dependents be collected, stored, gated on — then silently dropped + // on apply, leaving the field wiped on every push with nowhere for the value to go. + vi.mocked(getBlock).mockReturnValue({ type: 'jira', subBlocks: SUBS } as never) + const applied = applyDependentOverrides( + { + issueType: { value: 'old' }, + labelId: { value: 'old' }, + manualProjectId: { value: 'keep-me' }, + watchColumns: { value: 'keep-me' }, + }, + 'jira', + new Map([ + ['issueType', 'Bug'], + ['labelId', 'LABEL_1'], + ['manualProjectId', 'hacked'], + ['watchColumns', 'hacked'], + ]) + ) + expect(applied.issueType).toEqual({ value: 'Bug' }) + expect(applied.labelId).toEqual({ value: 'LABEL_1' }) + // Not offered, so not writable — an override naming one must not slip through. + expect(applied.manualProjectId).toEqual({ value: 'keep-me' }) + expect(applied.watchColumns).toEqual({ value: 'keep-me' }) + }) +}) diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts index 6967a88a02f..6b1317628b1 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts @@ -1629,6 +1629,48 @@ function applyNestedToolOverrides( * set a parent/credential field (bypassing mapping validation) or inject a bogus subblock. * Returns a new record only when something applied. */ +/** Sub-block types the fork sync modal renders as a free-text field rather than a picker. */ +export const TEXT_DEPENDENT_TYPES = new Set(['short-input', 'long-input']) + +/** + * The dependents of a remapped parent that the sync modal can offer AND the sync can apply. + * + * ONE definition on purpose. The collector and the apply side each encoded this rule separately + * and drifted the moment text fields were added: they were collected, stored, and gated on by + * the Sync button, then dropped here because the allowlist still demanded a `selectorKey`. The + * field stayed wiped on every push and the typed value went nowhere. + * + * A text member of a canonical pair whose basic side is a selector is excluded: the pair is + * already represented by its selector member, and the manual member is verbatim by policy. + */ +export function reconfigurableDependentIds( + subBlocks: ReadonlyArray<{ + id?: string + type?: string + dependsOn?: unknown + selectorKey?: string + canonicalParamId?: string + }> +): Set { + const canonicalWithSelector = new Set( + subBlocks + .filter((cfg) => cfg.canonicalParamId && cfg.selectorKey) + .map((cfg) => cfg.canonicalParamId) + ) + const allowed = new Set() + for (const cfg of subBlocks) { + if (!cfg.id || !cfg.dependsOn) continue + if (cfg.selectorKey) { + allowed.add(cfg.id) + continue + } + if (!TEXT_DEPENDENT_TYPES.has(cfg.type ?? '')) continue + if (cfg.canonicalParamId && canonicalWithSelector.has(cfg.canonicalParamId)) continue + allowed.add(cfg.id) + } + return allowed +} + export function applyDependentOverrides( subBlocks: SubBlockRecord, blockType: string, @@ -1637,12 +1679,10 @@ export function applyDependentOverrides( const config = getBlock(blockType) if (!config || overrides.size === 0) return subBlocks - const allowedTopLevel = new Set() + const allowedTopLevel = reconfigurableDependentIds(config.subBlocks) const toolInputIds = new Set() for (const cfg of config.subBlocks) { - if (!cfg.id) continue - if (cfg.dependsOn && cfg.selectorKey) allowedTopLevel.add(cfg.id) - if (cfg.type === 'tool-input') toolInputIds.add(cfg.id) + if (cfg.id && cfg.type === 'tool-input') toolInputIds.add(cfg.id) } const nestedByTool = new Map>() diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.ts b/apps/sim/hooks/queries/dynamic-subblock-options.ts index ff49ee87888..28059cd1f3d 100644 --- a/apps/sim/hooks/queries/dynamic-subblock-options.ts +++ b/apps/sim/hooks/queries/dynamic-subblock-options.ts @@ -1,8 +1,13 @@ -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { useQueries } from '@tanstack/react-query' +import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context' import { summarizeNames } from '@/lib/workflows/subblocks/display' import type { SubBlockConfig } from '@/blocks/types' import { getSelectorDefinition } from '@/hooks/selectors/registry' +import type { SelectorContext } from '@/hooks/selectors/types' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' export const DYNAMIC_SUBBLOCK_OPTION_STALE_TIME = 30 * 1000 @@ -49,6 +54,27 @@ export function useDynamicSubBlockOptionDisplayName({ // per-block resolver any more, so a selector without one simply renders the raw id. const definition = subBlock?.selectorKey ? getSelectorDefinition(subBlock.selectorKey) : undefined const fetchById = definition?.fetchById + + /** + * The block's own values, the same context the canvas builds. A `workspaceId`-only context + * silently fails every selector scoped by a sibling — `workspace.credentialGroupProviders` + * needs the group before it can name a provider, so the card fell back to raw ids. + */ + const buildResolverContext = useCallback((): SelectorContext => { + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + const block = blockId ? useWorkflowStore.getState().blocks[blockId] : undefined + if (!block?.type || !blockId) return { workspaceId } + const live = activeWorkflowId + ? (useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] ?? {}) + : {} + const merged: Record = { ...(block.subBlocks ?? {}) } + for (const [id, value] of Object.entries(live)) merged[id] = { ...merged[id], value } + return buildSelectorContextFromBlock(block.type, merged, { + workflowId: activeWorkflowId ?? undefined, + workspaceId, + canonicalModes: block.data?.canonicalModes, + }) + }, [blockId, workspaceId]) const canResolve = Boolean(blockId && fetchById && optionIds.length > 0) const queries = useQueries({ @@ -61,7 +87,7 @@ export function useDynamicSubBlockOptionDisplayName({ } return fetchById({ key: definition.key, - context: { workspaceId }, + context: buildResolverContext(), detailId: optionId, signal, }) From cc8e7e2cfbd161672a0701c4b8d21edc9fe3e933 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 21:27:15 -0700 Subject: [PATCH 14/14] fix(queries): scope the sub-block label cache by the selector's own context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-on to 1f423ab5, and a real gap in it. That commit taught `fetchById` to read sibling context but left the React Query key at `(workspaceId, blockId, subBlockId, optionId)`. A label resolved before its sibling was set — `workspace.credentialGroupProviders` with no group picked, which returns `null` — stayed cached under the same key and was reused once the group WAS picked, so the card kept showing the raw id. Changing between two groups collided the same way. This is the repo's own React Query rule ("every identifier the queryFn forwards into the fetch must appear in the queryKey"); `check:react-query` did not catch it because the context is built in the hook rather than passed as a named arg. The key now carries the selector's OWN `getQueryKey` for that context, rather than a second hand-maintained list of context fields. The cache is scoped by exactly what the selector reads, and stays correct if a selector's dependencies change later. The context also became reactive (subscribed rather than read via `getState()`), which is what lets the key move when the sibling does. Co-Authored-By: Claude Opus 5 (1M context) --- .../queries/dynamic-subblock-options.test.tsx | 23 +++++++- .../hooks/queries/dynamic-subblock-options.ts | 59 +++++++++++++++---- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx b/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx index ca5b648ab29..279217f330a 100644 --- a/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx +++ b/apps/sim/hooks/queries/dynamic-subblock-options.test.tsx @@ -15,7 +15,10 @@ vi.mock('@/hooks/selectors/registry', () => ({ })) import type { SubBlockConfig } from '@/blocks/types' -import { useDynamicSubBlockOptionDisplayName } from '@/hooks/queries/dynamic-subblock-options' +import { + dynamicSubBlockOptionKeys, + useDynamicSubBlockOptionDisplayName, +} from '@/hooks/queries/dynamic-subblock-options' import type { SelectorDefinition, SelectorKey } from '@/hooks/selectors/types' /** Any registered key; the hook only uses it to look the definition up. */ @@ -126,4 +129,22 @@ describe('useDynamicSubBlockOptionDisplayName', () => { await waitForResult(() => expect(hook.result()).toBe('Gmail, Slack')) }) + + it('re-resolves a label when the sibling its selector depends on changes', () => { + // The bug: `fetchById` reads sibling context, but the cache key did not, so a label + // resolved before a credential group was picked (null) stayed cached after it was, and the + // card kept showing the raw id. The key now carries the selector's OWN query key, which + // names every context field its result depends on. + const keyFor = (credentialGroupId?: string) => + dynamicSubBlockOptionKeys.detail('workspace-1', 'block-1', 'providerFilter', 'gmail', [ + 'selectors', + 'workspace.credentialGroupProviders', + 'workspace-1', + credentialGroupId ?? 'none', + ]) + + expect(keyFor(undefined)).not.toEqual(keyFor('group-1')) + expect(keyFor('group-1')).not.toEqual(keyFor('group-2')) + expect(keyFor('group-1')).toEqual(keyFor('group-1')) + }) }) diff --git a/apps/sim/hooks/queries/dynamic-subblock-options.ts b/apps/sim/hooks/queries/dynamic-subblock-options.ts index 28059cd1f3d..d62f68f3293 100644 --- a/apps/sim/hooks/queries/dynamic-subblock-options.ts +++ b/apps/sim/hooks/queries/dynamic-subblock-options.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useMemo } from 'react' import { useQueries } from '@tanstack/react-query' import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context' import { summarizeNames } from '@/lib/workflows/subblocks/display' @@ -14,13 +14,26 @@ export const DYNAMIC_SUBBLOCK_OPTION_STALE_TIME = 30 * 1000 export const dynamicSubBlockOptionKeys = { all: ['dynamic-subblock-options'] as const, details: () => [...dynamicSubBlockOptionKeys.all, 'detail'] as const, - detail: (workspaceId?: string, blockId?: string, subBlockId?: string, optionId?: string) => + /** + * `selectorScope` is the selector's OWN query key for this context — every context field its + * result depends on, named by the selector rather than restated here. Without it a label + * resolved under an empty or previous sibling (no credential group picked yet) stays cached + * and is reused once the sibling is set, so the card keeps showing a raw id or a stale name. + */ + detail: ( + workspaceId?: string, + blockId?: string, + subBlockId?: string, + optionId?: string, + selectorScope: readonly unknown[] = [] + ) => [ ...dynamicSubBlockOptionKeys.details(), workspaceId ?? '', blockId ?? '', subBlockId ?? '', optionId ?? '', + ...selectorScope, ] as const, } @@ -60,34 +73,54 @@ export function useDynamicSubBlockOptionDisplayName({ * silently fails every selector scoped by a sibling — `workspace.credentialGroupProviders` * needs the group before it can name a provider, so the card fell back to raw ids. */ - const buildResolverContext = useCallback((): SelectorContext => { - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - const block = blockId ? useWorkflowStore.getState().blocks[blockId] : undefined - if (!block?.type || !blockId) return { workspaceId } - const live = activeWorkflowId - ? (useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] ?? {}) - : {} + const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId) + const block = useWorkflowStore((state) => (blockId ? state.blocks[blockId] : undefined)) + const liveValues = useSubBlockStore((state) => + activeWorkflowId && blockId ? state.workflowValues[activeWorkflowId]?.[blockId] : undefined + ) + + const resolverContext = useMemo((): SelectorContext => { + if (!block?.type) return { workspaceId } const merged: Record = { ...(block.subBlocks ?? {}) } - for (const [id, value] of Object.entries(live)) merged[id] = { ...merged[id], value } + for (const [id, value] of Object.entries(liveValues ?? {})) { + merged[id] = { ...merged[id], value } + } return buildSelectorContextFromBlock(block.type, merged, { workflowId: activeWorkflowId ?? undefined, workspaceId, canonicalModes: block.data?.canonicalModes, }) - }, [blockId, workspaceId]) + }, [block, liveValues, activeWorkflowId, workspaceId]) + + /** + * The selector's own key for this context. Reusing it means the cache is scoped by exactly + * what the selector reads — no second list of context fields to keep in step, and it stays + * correct when a selector's dependencies change. + */ + const selectorScope = useMemo( + () => + definition ? definition.getQueryKey({ key: definition.key, context: resolverContext }) : [], + [definition, resolverContext] + ) const canResolve = Boolean(blockId && fetchById && optionIds.length > 0) const queries = useQueries({ queries: canResolve ? optionIds.map((optionId) => ({ - queryKey: dynamicSubBlockOptionKeys.detail(workspaceId, blockId, subBlock?.id, optionId), + queryKey: dynamicSubBlockOptionKeys.detail( + workspaceId, + blockId, + subBlock?.id, + optionId, + selectorScope as readonly unknown[] + ), queryFn: ({ signal }) => { if (!blockId || !fetchById || !definition) { throw new Error('Dynamic subblock option resolver is required') } return fetchById({ key: definition.key, - context: buildResolverContext(), + context: resolverContext, detailId: optionId, signal, })