Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
faa498d
fix(workspace-forking): stop double-labelling a custom block's inputs…
icecrasher321 Aug 20, 2026
e2d4049
fix(workspace-forking): resolve a custom block's inputs against the t…
icecrasher321 Aug 20, 2026
32015e3
fix(workspace-forking): stop a required file input deadlocking Sync
icecrasher321 Aug 20, 2026
cda0b92
refactor(sub-blocks): make a registered selector the single source fo…
icecrasher321 Aug 20, 2026
749662d
refactor(triggers): move every credential-scoped option list onto a r…
icecrasher321 Aug 20, 2026
454174a
refactor(triggers): move the table trigger's column picker onto table…
icecrasher321 Aug 20, 2026
ca070bf
refactor(managed-agent): move its four pickers onto registered selectors
icecrasher321 Aug 20, 2026
3c759a2
refactor(sub-blocks): delete fetchOptions — a sub-block's options are…
icecrasher321 Aug 20, 2026
7807555
feat(workspace-forking): make every fork-clearable sub-block reconfig…
icecrasher321 Aug 20, 2026
f2499f3
fix(sub-blocks): stop selector-backed fields rendering undefined opti…
icecrasher321 Aug 20, 2026
924cbab
fix(selectors): restore the credential-group provider label resolver,…
icecrasher321 Aug 20, 2026
d702e59
chore: re-record the page-graph baseline after staging's growth consu…
icecrasher321 Aug 20, 2026
1f423ab
fix(workspace-forking): actually apply text dependents, and resolve l…
icecrasher321 Aug 20, 2026
cc8e7e2
fix(queries): scope the sub-block label cache by the selector's own c…
icecrasher321 Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .agents/skills/add-block/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<service>/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.
31 changes: 31 additions & 0 deletions .agents/skills/add-trigger/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<service>/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
Expand Down
1 change: 1 addition & 0 deletions .claude/rules/sim-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Block.output>` 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.
2 changes: 1 addition & 1 deletion apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ 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 { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'

/**
Expand All @@ -26,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<NonNullable<SubBlockConfig['createAction']>, string> = {
sandbox: 'Create Sandbox',
}
Expand All @@ -48,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<string, unknown> }) => ComboBoxOption[])
/** Default value to use when no value is set */
defaultValue?: string
/** ID of the parent block */
Expand All @@ -68,13 +77,10 @@ interface ComboBoxProps {
placeholder?: string
/** Configuration for the sub-block */
config: SubBlockConfig
/** Async function to fetch options dynamically */
fetchOptions?: (blockId: string) => Promise<Array<{ label: string; id: string }>>
/** Async function to fetch a single option's label by ID (for hydration) */
fetchOptionById?: (
blockId: string,
optionId: string
) => Promise<{ label: string; id: string } | null>
/** Registered selector supplying the options. The canonical source for a remote list. */
selectorKey?: SelectorKey
/** Drop the hosting workflow from a `sim.workflows` list. */
selectorExcludeSelf?: boolean
/** Field dependencies that trigger option refetch when changed */
dependsOn?: SubBlockConfig['dependsOn']
}
Expand All @@ -90,8 +96,8 @@ export const ComboBox = memo(function ComboBox({
disabled,
placeholder = 'Type or select an option...',
config,
fetchOptions,
fetchOptionById,
Comment thread
cursor[bot] marked this conversation as resolved.
selectorKey,
selectorExcludeSelf,
dependsOn,
}: ComboBoxProps) {
const activeSearchTarget = useActiveSearchTarget()
Expand All @@ -110,28 +116,41 @@ 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,
isLoadingOptions,
fetchError,
hydratedOption,
missingOptionId,
isDynamic,
refetch: refetchOptions,
} = useFetchedOptions({
blockId,
dependsOnFields,
fetchOptions,
fetchOptionById,
selectorKey,
selectorExcludeSelf,
isPreview: Boolean(isPreview),
disabled: Boolean(disabled),
valueToHydrate: value as string | null | undefined,
Expand Down Expand Up @@ -194,9 +213,9 @@ 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) {
if (subBlockId === 'model' && isDynamic && normalizedFetchedOptions.length > 0) {
opts = opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id))
}

Expand Down Expand Up @@ -224,7 +243,7 @@ export const ComboBox = memo(function ComboBox({

return opts
}, [
fetchOptions,
Comment thread
cursor[bot] marked this conversation as resolved.
isDynamic,
normalizedFetchedOptions,
staticOptions,
hydratedOption,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@ 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 { 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

Expand All @@ -39,8 +45,12 @@ type DropdownOption =
* Props for the Dropdown component
*/
interface DropdownProps {
/** Static options array or function that returns options */
options: DropdownOption[] | (() => 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<string, unknown> }) => DropdownOption[])
/** Default value to select when no value is set */
defaultValue?: string
/** Unique identifier for the block */
Expand All @@ -59,13 +69,10 @@ interface DropdownProps {
placeholder?: string
/** Enable multi-select mode */
multiSelect?: boolean
/** Async function to fetch options dynamically */
fetchOptions?: (blockId: string) => Promise<Array<{ label: string; id: string }>>
/** Async function to fetch a single option's label by ID (for hydration) */
fetchOptionById?: (
blockId: string,
optionId: string
) => Promise<{ label: string; id: string } | null>
/** Registered selector supplying the options. The canonical source for a remote list. */
selectorKey?: SelectorKey
/** 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 */
Expand Down Expand Up @@ -94,8 +101,8 @@ export const Dropdown = memo(function Dropdown({
disabled,
placeholder = 'Select an option...',
multiSelect = false,
fetchOptions,
fetchOptionById,
selectorKey,
selectorExcludeSelf,
dependsOn,
searchable = false,
preserveLabelCase = false,
Expand Down Expand Up @@ -136,21 +143,29 @@ 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])
if (typeof options === 'function') return options({ values: blockValues ?? {} })
return options ?? EMPTY_OPTIONS
}, [options, blockValues])

const {
fetchedOptions,
isLoadingOptions,
fetchError,
hydratedOption,
isDynamic,
refetch: refetchOptions,
} = useFetchedOptions({
blockId,
dependsOnFields,
fetchOptions,
fetchOptionById,
selectorKey,
selectorExcludeSelf,
isPreview: Boolean(isPreview),
disabled: Boolean(disabled),
valueToHydrate: singleValue,
Expand All @@ -175,9 +190,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) =>
Expand All @@ -189,7 +202,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ interface EnvVarDropdownProps {
/** Maximum height for the dropdown */
maxHeight?: string
/** Reference to the input element for caret positioning */
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement>
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement | null>
}

/**
Expand Down
Loading
Loading