Skip to content

Commit 2ced737

Browse files
refactor(sub-blocks): make a registered selector the single source for a remote option list, and close the fork-sync reconfiguration gap (#6878)
* fix(workspace-forking): stop double-labelling a custom block's inputs, and derive their controls from the canvas 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) <noreply@anthropic.com> * fix(workspace-forking): resolve a custom block's inputs against the target environment, and stop a re-sync wiping its uploads 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. - `<block.output>` 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) <noreply@anthropic.com> * fix(workspace-forking): stop a required file input deadlocking Sync 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) <noreply@anthropic.com> * refactor(sub-blocks): make a registered selector the single source for a remote option list `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) <noreply@anthropic.com> * refactor(triggers): move every credential-scoped option list onto a registered selector 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) <noreply@anthropic.com> * refactor(triggers): move the table trigger's column picker onto table.columns `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) <noreply@anthropic.com> * refactor(managed-agent): move its four pickers onto registered selectors 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) <noreply@anthropic.com> * refactor(sub-blocks): delete fetchOptions — a sub-block's options are a selector or derived, never both 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) <noreply@anthropic.com> * feat(workspace-forking): make every fork-clearable sub-block reconfigurable at sync time, and lint that it stays so `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) <noreply@anthropic.com> * fix(sub-blocks): stop selector-backed fields rendering undefined options, and pass derived values to ComboBox 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) <noreply@anthropic.com> * fix(selectors): restore the credential-group provider label resolver, and probe getQueryKey for missing dependsOn 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) <noreply@anthropic.com> * chore: re-record the page-graph baseline after staging's growth consumed its allowance 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) <noreply@anthropic.com> * fix(workspace-forking): actually apply text dependents, and resolve labels against the full selector context 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) <noreply@anthropic.com> * fix(queries): scope the sub-block label cache by the selector's own context Follow-on to 1f423ab, 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9a621bc commit 2ced737

66 files changed

Lines changed: 2606 additions & 1678 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/add-block/SKILL.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,3 +1052,34 @@ After creating the block, you MUST validate it against every tool it references:
10521052
4. **Verify conditions** — each subBlock should only show for the operations that actually use it
10531053
5. **Verify `{Service}BlockMeta` is exported** with at least 7 templates, each having `icon`, `title`, `prompt`, `modules`, `category`, and `tags`
10541054
6. **If any tool outputs are still unknown**, explicitly tell the user instead of guessing block outputs
1055+
1056+
## Option Lists: `selectorKey` or `options`, never a per-block fetcher
1057+
1058+
A sub-block gets its choices from exactly one of two places. There is no third.
1059+
1060+
**`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.
1061+
1062+
```ts
1063+
{ id: 'triggerCredentials', type: 'oauth-input', canonicalParamId: 'oauthCredential', mode: 'trigger' },
1064+
{ id: 'labelIds', type: 'dropdown', multiSelect: true,
1065+
selectorKey: 'gmail.labels', dependsOn: ['triggerCredentials'], mode: 'trigger' },
1066+
{ id: 'manualLabelIds', type: 'short-input', mode: 'trigger-advanced' },
1067+
```
1068+
1069+
`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.)
1070+
1071+
**`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.
1072+
1073+
```ts
1074+
options: (params) => {
1075+
const model = params?.values.model
1076+
return typeof model === 'string' ? effortsFor(model) : DEFAULT_EFFORTS
1077+
}
1078+
```
1079+
1080+
**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.
1081+
1082+
Two rules the checks enforce:
1083+
1084+
- **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`).
1085+
- **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.

.agents/skills/add-trigger/SKILL.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,37 @@ Add to `helm/sim/values.yaml` under the existing polling cron jobs:
472472
- Cursor-based (changes API): `apps/sim/lib/webhooks/polling/google-drive.ts`
473473
- Timestamp-based: `apps/sim/lib/webhooks/polling/google-calendar.ts`
474474

475+
## Option Lists: `selectorKey` or `options`, never a per-block fetcher
476+
477+
A sub-block gets its choices from exactly one of two places. There is no third.
478+
479+
**`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.
480+
481+
```ts
482+
{ id: 'triggerCredentials', type: 'oauth-input', canonicalParamId: 'oauthCredential', mode: 'trigger' },
483+
{ id: 'labelIds', type: 'dropdown', multiSelect: true,
484+
selectorKey: 'gmail.labels', dependsOn: ['triggerCredentials'], mode: 'trigger' },
485+
{ id: 'manualLabelIds', type: 'short-input', mode: 'trigger-advanced' },
486+
```
487+
488+
`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.)
489+
490+
**`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.
491+
492+
```ts
493+
options: (params) => {
494+
const model = params?.values.model
495+
return typeof model === 'string' ? effortsFor(model) : DEFAULT_EFFORTS
496+
}
497+
```
498+
499+
**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.
500+
501+
Two rules the checks enforce:
502+
503+
- **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`).
504+
- **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.
505+
475506
## Checklist
476507

477508
### Trigger Definition

.claude/rules/sim-integrations.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ The full authoring instructions — tool/block/icon/trigger scaffolding, SubBloc
1717
- 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).
1818
- `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.
1919
- 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.
20+
- 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.
2021
- 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.

apps/sim/app/api/workspaces/[id]/fork/diff/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export const GET = withRouteHandler(
154154
forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
155155
) ??
156156
readTargetDraftDependentValue(
157-
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
157+
targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId)?.subBlocks,
158158
sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId),
159159
field.subBlockKey
160160
),

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/c
1414
import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
1515
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
1616
import type { SubBlockConfig } from '@/blocks/types'
17+
import type { SelectorKey } from '@/hooks/selectors/types'
1718
import { usePermissionConfig } from '@/hooks/use-permission-config'
19+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1820
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
1921

2022
/**
@@ -26,6 +28,9 @@ const MIN_ZOOM = 0.1
2628
const MAX_ZOOM = 1
2729
const ZOOM_DURATION = 0
2830

31+
/** Shared empty list, so a selector-backed field with no static options keeps a stable identity. */
32+
const EMPTY_OPTIONS: ComboBoxOption[] = []
33+
2934
const CREATE_ACTION_LABEL: Record<NonNullable<SubBlockConfig['createAction']>, string> = {
3035
sandbox: 'Create Sandbox',
3136
}
@@ -48,8 +53,12 @@ type ComboBoxOption =
4853
* Props for the ComboBox component
4954
*/
5055
interface ComboBoxProps {
51-
/** Available options for selection - can be static array or function that returns options */
52-
options: ComboBoxOption[] | (() => ComboBoxOption[])
56+
/**
57+
* Static options, or a function deriving them from the block's own values. Absent on a
58+
* selector-backed field, whose list comes from `selectorKey` instead — so this must never
59+
* be read without a default.
60+
*/
61+
options?: ComboBoxOption[] | ((params?: { values: Record<string, unknown> }) => ComboBoxOption[])
5362
/** Default value to use when no value is set */
5463
defaultValue?: string
5564
/** ID of the parent block */
@@ -68,13 +77,10 @@ interface ComboBoxProps {
6877
placeholder?: string
6978
/** Configuration for the sub-block */
7079
config: SubBlockConfig
71-
/** Async function to fetch options dynamically */
72-
fetchOptions?: (blockId: string) => Promise<Array<{ label: string; id: string }>>
73-
/** Async function to fetch a single option's label by ID (for hydration) */
74-
fetchOptionById?: (
75-
blockId: string,
76-
optionId: string
77-
) => Promise<{ label: string; id: string } | null>
80+
/** Registered selector supplying the options. The canonical source for a remote list. */
81+
selectorKey?: SelectorKey
82+
/** Drop the hosting workflow from a `sim.workflows` list. */
83+
selectorExcludeSelf?: boolean
7884
/** Field dependencies that trigger option refetch when changed */
7985
dependsOn?: SubBlockConfig['dependsOn']
8086
}
@@ -90,8 +96,8 @@ export const ComboBox = memo(function ComboBox({
9096
disabled,
9197
placeholder = 'Type or select an option...',
9298
config,
93-
fetchOptions,
94-
fetchOptionById,
99+
selectorKey,
100+
selectorExcludeSelf,
95101
dependsOn,
96102
}: ComboBoxProps) {
97103
const activeSearchTarget = useActiveSearchTarget()
@@ -110,28 +116,41 @@ export const ComboBox = memo(function ComboBox({
110116
const { isModelUsable, isLoading: isPermissionLoading } = usePermissionConfig()
111117

112118
// Evaluate static options if provided as a function
119+
// Derived option lists read the block's own values (a model's valid reasoning efforts);
120+
// `dependsOn` already re-renders this control when one of those siblings changes.
121+
const activeWorkflowIdForValues = useWorkflowRegistry((state) => state.activeWorkflowId)
122+
const blockValues = useSubBlockStore((state) =>
123+
activeWorkflowIdForValues
124+
? state.workflowValues[activeWorkflowIdForValues]?.[blockId]
125+
: undefined
126+
)
127+
113128
const staticOptions = useMemo(() => {
114-
const opts = typeof options === 'function' ? options() : options
129+
const opts =
130+
typeof options === 'function'
131+
? options({ values: blockValues ?? {} })
132+
: (options ?? EMPTY_OPTIONS)
115133

116134
if (subBlockId === 'model') {
117135
return opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id))
118136
}
119137

120138
return opts
121-
}, [options, subBlockId, isModelUsable])
139+
}, [options, blockValues, subBlockId, isModelUsable])
122140

123141
const {
124142
fetchedOptions,
125143
isLoadingOptions,
126144
fetchError,
127145
hydratedOption,
128146
missingOptionId,
147+
isDynamic,
129148
refetch: refetchOptions,
130149
} = useFetchedOptions({
131150
blockId,
132151
dependsOnFields,
133-
fetchOptions,
134-
fetchOptionById,
152+
selectorKey,
153+
selectorExcludeSelf,
135154
isPreview: Boolean(isPreview),
136155
disabled: Boolean(disabled),
137156
valueToHydrate: value as string | null | undefined,
@@ -194,9 +213,9 @@ export const ComboBox = memo(function ComboBox({
194213
// Merge static and fetched options - fetched options take priority when available
195214
const evaluatedOptions = useMemo((): ComboBoxOption[] => {
196215
let opts: ComboBoxOption[] =
197-
fetchOptions && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
216+
isDynamic && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : staticOptions
198217

199-
if (subBlockId === 'model' && fetchOptions && normalizedFetchedOptions.length > 0) {
218+
if (subBlockId === 'model' && isDynamic && normalizedFetchedOptions.length > 0) {
200219
opts = opts.filter((opt) => isModelUsable(typeof opt === 'string' ? opt : opt.id))
201220
}
202221

@@ -224,7 +243,7 @@ export const ComboBox = memo(function ComboBox({
224243

225244
return opts
226245
}, [
227-
fetchOptions,
246+
isDynamic,
228247
normalizedFetchedOptions,
229248
staticOptions,
230249
hydratedOption,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,15 @@ import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflow
1515
import { getBlock } from '@/blocks/registry'
1616
import type { SubBlockConfig } from '@/blocks/types'
1717
import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler'
18+
import type { SelectorKey } from '@/hooks/selectors/types'
1819
import { useOperationAccess } from '@/hooks/use-operation-access'
20+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
21+
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
1922
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
2023

24+
/** Shared empty list, so a selector-backed field with no static options keeps a stable identity. */
25+
const EMPTY_OPTIONS: DropdownOption[] = []
26+
2127
/** Selected-value badges shown before folding the rest into a "+N" badge. */
2228
const MAX_VISIBLE_MULTI_SELECT_BADGES = 2
2329

@@ -39,8 +45,12 @@ type DropdownOption =
3945
* Props for the Dropdown component
4046
*/
4147
interface DropdownProps {
42-
/** Static options array or function that returns options */
43-
options: DropdownOption[] | (() => DropdownOption[])
48+
/**
49+
* Static options, or a function deriving them from the block's own values. Absent on a
50+
* selector-backed field, whose list comes from `selectorKey` instead — so this must never
51+
* be read without a default.
52+
*/
53+
options?: DropdownOption[] | ((params?: { values: Record<string, unknown> }) => DropdownOption[])
4454
/** Default value to select when no value is set */
4555
defaultValue?: string
4656
/** Unique identifier for the block */
@@ -59,13 +69,10 @@ interface DropdownProps {
5969
placeholder?: string
6070
/** Enable multi-select mode */
6171
multiSelect?: boolean
62-
/** Async function to fetch options dynamically */
63-
fetchOptions?: (blockId: string) => Promise<Array<{ label: string; id: string }>>
64-
/** Async function to fetch a single option's label by ID (for hydration) */
65-
fetchOptionById?: (
66-
blockId: string,
67-
optionId: string
68-
) => Promise<{ label: string; id: string } | null>
72+
/** Registered selector supplying the options. The canonical source for a remote list. */
73+
selectorKey?: SelectorKey
74+
/** Drop the hosting workflow from a `sim.workflows` list. */
75+
selectorExcludeSelf?: boolean
6976
/** Field dependencies that trigger option refetch when changed */
7077
dependsOn?: SubBlockConfig['dependsOn']
7178
/** Enable search input in dropdown */
@@ -94,8 +101,8 @@ export const Dropdown = memo(function Dropdown({
94101
disabled,
95102
placeholder = 'Select an option...',
96103
multiSelect = false,
97-
fetchOptions,
98-
fetchOptionById,
104+
selectorKey,
105+
selectorExcludeSelf,
99106
dependsOn,
100107
searchable = false,
101108
preserveLabelCase = false,
@@ -136,21 +143,29 @@ export const Dropdown = memo(function Dropdown({
136143
: []
137144
: null
138145

146+
// Derived option lists read the block's own values (a model's valid reasoning efforts);
147+
// `dependsOn` already re-renders this control when one of those siblings changes.
148+
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
149+
const blockValues = useSubBlockStore((state) =>
150+
activeWorkflowId ? state.workflowValues[activeWorkflowId]?.[blockId] : undefined
151+
)
139152
const evaluatedOptions = useMemo(() => {
140-
return typeof options === 'function' ? options() : options
141-
}, [options])
153+
if (typeof options === 'function') return options({ values: blockValues ?? {} })
154+
return options ?? EMPTY_OPTIONS
155+
}, [options, blockValues])
142156

143157
const {
144158
fetchedOptions,
145159
isLoadingOptions,
146160
fetchError,
147161
hydratedOption,
162+
isDynamic,
148163
refetch: refetchOptions,
149164
} = useFetchedOptions({
150165
blockId,
151166
dependsOnFields,
152-
fetchOptions,
153-
fetchOptionById,
167+
selectorKey,
168+
selectorExcludeSelf,
154169
isPreview: Boolean(isPreview),
155170
disabled: Boolean(disabled),
156171
valueToHydrate: singleValue,
@@ -175,9 +190,7 @@ export const Dropdown = memo(function Dropdown({
175190

176191
const allOptions = useMemo(() => {
177192
let opts: DropdownOption[] =
178-
fetchOptions && normalizedFetchedOptions.length > 0
179-
? normalizedFetchedOptions
180-
: evaluatedOptions
193+
isDynamic && normalizedFetchedOptions.length > 0 ? normalizedFetchedOptions : evaluatedOptions
181194

182195
if (hydratedOption) {
183196
const alreadyPresent = opts.some((o) =>
@@ -189,7 +202,7 @@ export const Dropdown = memo(function Dropdown({
189202
}
190203

191204
return opts
192-
}, [fetchOptions, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
205+
}, [isDynamic, normalizedFetchedOptions, evaluatedOptions, hydratedOption])
193206

194207
/**
195208
* Operation IDs whose resolved tool is denied by the caller's permission

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown/env-var-dropdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ interface EnvVarDropdownProps {
3939
/** Maximum height for the dropdown */
4040
maxHeight?: string
4141
/** Reference to the input element for caret positioning */
42-
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement>
42+
inputRef?: React.RefObject<HTMLTextAreaElement | HTMLInputElement | null>
4343
}
4444

4545
/**

0 commit comments

Comments
 (0)