diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts index d91517dd389..20a1c079ab9 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts @@ -9,6 +9,7 @@ import { effectiveCopyDependentValue, effectiveDependentValue, getActionableDependentFields, + getDisplayedDependentFields, isDependentConfigurationActionable, } from '@/ee/workspace-forking/components/fork-sync/dependent-value' @@ -167,6 +168,46 @@ describe('applyDependentRepick', () => { [dependentKey(unrelated)]: 'still-keep-me', }) }) + + it('does not clear a descendant belonging to another nested tool instance', () => { + const projectOne = field({ + subBlockKey: 'tools[0].projectId', + dependencyScope: 'tools[0]', + providesContextKey: 'projectId', + }) + const issueOne = field({ + subBlockKey: 'tools[0].issueKey', + dependencyScope: 'tools[0]', + consumesContextKeys: ['projectId'], + }) + const projectTwo = field({ + subBlockKey: 'tools[1].projectId', + dependencyScope: 'tools[1]', + providesContextKey: 'projectId', + }) + const issueTwo = field({ + subBlockKey: 'tools[1].issueKey', + dependencyScope: 'tools[1]', + consumesContextKeys: ['projectId'], + }) + const previous = { + [dependentKey(issueOne)]: 'P1-1', + [dependentKey(issueTwo)]: 'P2-1', + } + + expect( + applyDependentRepick( + previous, + projectOne, + [projectOne, issueOne, projectTwo, issueTwo], + 'P1-NEW' + ) + ).toEqual({ + [dependentKey(projectOne)]: 'P1-NEW', + [dependentKey(issueOne)]: '', + [dependentKey(issueTwo)]: 'P2-1', + }) + }) }) describe('isDependentConfigurationActionable', () => { @@ -341,4 +382,63 @@ describe('getActionableDependentFields', () => { ).map((dependent) => dependent.subBlockKey) ).toEqual(['siteId', 'driveId', 'spreadsheetId']) }) + + it('finds a required child provider only within the same nested tool instance', () => { + const projectOne = field({ + subBlockKey: 'tools[0].projectId', + dependencyScope: 'tools[0]', + providesContextKey: 'projectId', + }) + const projectTwo = field({ + subBlockKey: 'tools[1].projectId', + dependencyScope: 'tools[1]', + providesContextKey: 'projectId', + }) + const issueOne = field({ + subBlockKey: 'tools[0].issueKey', + dependencyScope: 'tools[0]', + currentValue: '', + required: true, + consumesContextKeys: ['projectId'], + }) + + expect( + getActionableDependentFields( + [projectOne, projectTwo, issueOne], + {}, + unchangedMappedParent + ).map((dependent) => dependent.subBlockKey) + ).toEqual(['tools[0].projectId', 'tools[0].issueKey']) + }) +}) + +describe('getDisplayedDependentFields', () => { + const unchangedMappedParent = { + parentResolved: true, + parentChanged: false, + copying: false, + } + + it('reveals configured and optional fields only after the explicit edit action', () => { + const configuredRequired = field({ subBlockKey: 'projectId', required: true }) + const optional = field({ subBlockKey: 'issueKey', currentValue: '', required: false }) + + expect( + getDisplayedDependentFields([configuredRequired, optional], {}, unchangedMappedParent, false) + ).toEqual([]) + expect( + getDisplayedDependentFields([configuredRequired, optional], {}, unchangedMappedParent, true) + ).toEqual([configuredRequired, optional]) + }) + + it('never shows selectors before their parent mapping is resolved', () => { + expect( + getDisplayedDependentFields( + [field()], + {}, + { ...unchangedMappedParent, parentResolved: false }, + true + ) + ).toEqual([]) + }) }) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts index 84cd3a2dc36..9ae7ab40d84 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts @@ -5,6 +5,10 @@ export function dependentKey(dependent: ForkDependentReconfig): string { return `${dependent.targetWorkflowId}:${dependent.targetBlockId}:${dependent.subBlockKey}` } +function sameDependencyScope(left: ForkDependentReconfig, right: ForkDependentReconfig): boolean { + return left.dependencyScope === right.dependencyScope +} + /** * Store a dependent re-pick and clear every selector transitively scoped by it. Empty-string * overrides are intentional: an absent override means "fall back to the stored value", while a @@ -28,7 +32,13 @@ export function applyDependentRepick( for (const field of blockFields) { const fieldKey = dependentKey(field) - if (visitedFields.has(fieldKey) || !field.consumesContextKeys.includes(contextKey)) continue + if ( + !sameDependencyScope(changedField, field) || + visitedFields.has(fieldKey) || + !field.consumesContextKeys.includes(contextKey) + ) { + continue + } visitedFields.add(fieldKey) nextState[fieldKey] = '' @@ -106,9 +116,15 @@ export function getActionableDependentFields( const actionable = new Set( fields.filter((field) => isDependentConfigurationActionable(field, reconfig, state)) ) - const providersByContextKey = new Map() + const providersByScope = new Map>() for (const field of fields) { - if (field.providesContextKey) providersByContextKey.set(field.providesContextKey, field) + if (!field.providesContextKey) continue + let providers = providersByScope.get(field.dependencyScope) + if (!providers) { + providers = new Map() + providersByScope.set(field.dependencyScope, providers) + } + providers.set(field.providesContextKey, field) } const pending = Array.from(actionable) @@ -116,7 +132,7 @@ export function getActionableDependentFields( const field = pending[index] if (!field) continue for (const contextKey of field.consumesContextKeys) { - const provider = providersByContextKey.get(contextKey) + const provider = providersByScope.get(field.dependencyScope)?.get(contextKey) if (!provider || actionable.has(provider)) continue actionable.add(provider) pending.push(provider) @@ -125,3 +141,18 @@ export function getActionableDependentFields( return fields.filter((field) => actionable.has(field)) } + +/** + * Fields rendered in the mapping UI. Required missing fields remain visible by default; an + * explicit edit action reveals every active selector under a resolved parent without changing + * which fields gate Sync. + */ +export function getDisplayedDependentFields( + fields: ForkDependentReconfig[], + reconfig: Record, + state: DependentConfigurationState, + showConfigured: boolean +): ForkDependentReconfig[] { + if (!state.parentResolved) return [] + return showConfigured ? fields : getActionableDependentFields(fields, reconfig, state) +} 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 2042ab03b61..a0cc46362e0 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 @@ -39,7 +39,8 @@ import { dependentKey, effectiveCopyDependentValue, effectiveDependentValue, - getActionableDependentFields, + getDisplayedDependentFields, + isDependentConfigurationActionable, } from '@/ee/workspace-forking/components/fork-sync/dependent-value' import type { ForkKindSummary, @@ -109,7 +110,8 @@ function groupDependentsByWorkflow( workflows: ForkResourceUsage['workflows'], dependents: ForkDependentReconfig[], reconfig: Record, - state: DependentConfigurationState + state: DependentConfigurationState, + showConfigured: boolean ): WorkflowDependents[] { const byWorkflow = new Map() for (const dependent of dependents) { @@ -138,7 +140,12 @@ function groupDependentsByWorkflow( blocks: Array.from(byBlock.values()) .map((block) => ({ ...block, - configurableFields: getActionableDependentFields(block.fields, reconfig, state), + configurableFields: getDisplayedDependentFields( + block.fields, + reconfig, + state, + showConfigured + ), })) .filter((block) => block.configurableFields.length > 0) .sort((a, b) => a.blockName.localeCompare(b.blockName)), @@ -149,11 +156,13 @@ function groupDependentsByWorkflow( /** Chain state for one block: the SelectorContext values its parent fields provide. */ function blockChainState( block: DependentBlock, + activeField: ForkDependentReconfig, effectiveValue: (field: ForkDependentReconfig) => string ) { const providedValues: Record = {} const providedContextKeys = new Set() for (const field of block.fields) { + if (field.dependencyScope !== activeField.dependencyScope) continue if (field.providesContextKey) { providedContextKeys.add(field.providesContextKey) const value = effectiveValue(field) @@ -199,7 +208,7 @@ function DependentSelector({ copying ? effectiveCopyDependentValue(f, reconfig) : effectiveDependentValue(f, reconfig, parentChanged) - const { providedValues, providedContextKeys } = blockChainState(block, effectiveValue) + const { providedValues, providedContextKeys } = blockChainState(block, field, effectiveValue) // Disabled until every in-block parent it depends on has a value, so a child never queries // a stale upstream value. const ready = field.consumesContextKeys.every( @@ -230,6 +239,7 @@ function DependentSelector({ interface DependentWorkflowCardProps { workflow: WorkflowDependents + initiallyExpanded: boolean target: string parentChanged: boolean /** True when the parent is resolved by COPY - the selectors browse the SOURCE parent. */ @@ -244,10 +254,12 @@ interface DependentWorkflowCardProps { * One workflow's dependent fields as a collapsible card (the same `CollapsibleCard` the table * workflow sidebar's input mapping and the enrichment config use): the header names the * workflow; the body groups fields under block → optional tool → plain field label. - * Cards holding a required field start expanded - a required field is what gates Sync. + * Cards holding a required field start expanded because that field gates Sync. Cards first + * revealed by explicit edit mode also start expanded so the edit action exposes its controls. */ function DependentWorkflowCard({ workflow, + initiallyExpanded, target, parentChanged, copying, @@ -257,7 +269,9 @@ function DependentWorkflowCard({ setReconfig, }: DependentWorkflowCardProps) { const [collapsed, setCollapsed] = useState( - () => !workflow.blocks.some((block) => block.configurableFields.some((field) => field.required)) + () => + !initiallyExpanded && + !workflow.blocks.some((block) => block.configurableFields.some((field) => field.required)) ) return ( {workflow.blocks.map((block) => { const topLevel = block.configurableFields.filter((field) => !field.toolName) - const byTool = new Map() + const byTool = new Map() for (const field of block.configurableFields) { if (!field.toolName) continue - const list = byTool.get(field.toolName) - if (list) list.push(field) - else byTool.set(field.toolName, [field]) + const scope = field.dependencyScope ?? field.toolName + const group = byTool.get(scope) + if (group) group.fields.push(field) + else byTool.set(scope, { name: field.toolName, fields: [field] }) } - const toolGroups = Array.from(byTool.entries()).sort(([a], [b]) => a.localeCompare(b)) + const toolGroups = Array.from(byTool.entries()).sort(([, a], [, b]) => + a.name.localeCompare(b.name) + ) return (
@@ -299,10 +316,10 @@ function DependentWorkflowCard({ />
))} - {toolGroups.map(([toolName, fields]) => ( -
- {toolName} - {fields.map((field) => ( + {toolGroups.map(([scope, tool]) => ( +
+ {tool.name} + {tool.fields.map((field) => (
diff --git a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts index 9895edff498..31b628e93bd 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts @@ -466,6 +466,7 @@ describe('collectForkDependentReconfigs', () => { selectorKey: 'gmail.labels', title: 'Label', toolName: 'Gmail 1', + dependencyScope: 'tools[0]', currentValue: 'INBOX', required: true, consumesContextKeys: [], @@ -475,6 +476,95 @@ describe('collectForkDependentReconfigs', () => { ]) }) + it('preserves dependency chains independently for duplicate nested tool instances', () => { + vi.mocked(getBlock).mockImplementation((type) => { + if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) + if (type === 'jira') + return blockWith([ + { + id: 'credential', + title: 'Jira Account', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + }, + { + id: 'projectId', + title: 'Project', + type: 'project-selector', + canonicalParamId: 'projectId', + selectorKey: 'jira.projects', + dependsOn: ['credential'], + }, + { + id: 'issueKey', + title: 'Issue', + type: 'file-selector', + selectorKey: 'jira.issues', + dependsOn: ['credential', 'projectId'], + required: true, + }, + ]) + return undefined as unknown as BlockConfig + }) + const states = new Map([ + [ + 'wf-src', + sourceState('agent', { + tools: { + value: [ + { + type: 'jira', + title: 'Jira', + params: { credential: 'cred-src', projectId: 'P1', issueKey: 'P1-1' }, + }, + { + type: 'jira', + title: 'Jira', + params: { credential: 'cred-src', projectId: 'P2', issueKey: 'P2-1' }, + }, + ], + }, + }), + ], + ]) + + const result = collectForkDependentReconfigs([replaceItem], states, resolve) + + expect( + result.map((entry) => ({ + key: entry.subBlockKey, + scope: entry.dependencyScope, + provides: entry.providesContextKey, + consumes: entry.consumesContextKeys, + })) + ).toEqual([ + { + key: 'tools[0].projectId', + scope: 'tools[0]', + provides: 'projectId', + consumes: [], + }, + { + key: 'tools[0].issueKey', + scope: 'tools[0]', + provides: undefined, + consumes: ['projectId'], + }, + { + key: 'tools[1].projectId', + scope: 'tools[1]', + provides: 'projectId', + consumes: [], + }, + { + key: 'tools[1].issueKey', + scope: 'tools[1]', + provides: undefined, + consumes: ['projectId'], + }, + ]) + }) + it('honors a nested tool-scoped advanced override (manual mode passes through - no re-pick)', () => { vi.mocked(getBlock).mockImplementation((type) => { if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) 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 8982e9b0ef1..9d52ec1ce21 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts @@ -80,12 +80,10 @@ interface EmitAnchoredParams { /** Nested `tool-input` tool display name; omitted for top-level block subblocks. */ toolName?: string /** - * Emit `providesContextKey`/`consumesContextKeys` so the modal can chain in-block - * re-picks. Top-level chains; nested tool params don't (a tool's chain would need - * per-tool context scoping - out of scope - and the common nested case is a single - * credential-anchored field). + * Stable nested-tool instance boundary for dependency context and descendant invalidation. + * Omitted for top-level block subblocks, which share the block scope. */ - chaining: boolean + dependencyScope?: string /** * Present ONLY for the nested `tool-input` pass: each param's resolved * {@link ParameterVisibility}, keyed by sub-block id and by canonical param id. Its presence @@ -120,7 +118,7 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { makeSubBlockKey, makeTitle, toolName, - chaining, + dependencyScope, paramVisibilityById, out, } = params @@ -185,20 +183,17 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { // is offered exactly once. if (seen.has(canonicalKey)) continue seen.add(canonicalKey) - const providesContextKey = - chaining && isSelectorContextKey(canonicalKey) ? canonicalKey : undefined + const providesContextKey = isSelectorContextKey(canonicalKey) ? canonicalKey : undefined // The SelectorContext keys this field needs from in-block siblings (e.g. a sheet // needs the spreadsheet), excluding the anchor key the modal already supplies, so // the modal can keep a child disabled until its re-picked parent is chosen. - const consumesContextKeys = chaining - ? [ - ...new Set( - getDependsOnFields(dependent.dependsOn) - .map((parent) => canonicalIndex.canonicalIdBySubBlockId[parent] ?? parent) - .filter((key) => key !== anchor.parentContextKey && isSelectorContextKey(key)) - ), - ] - : [] + const consumesContextKeys = [ + ...new Set( + getDependsOnFields(dependent.dependsOn) + .map((parent) => canonicalIndex.canonicalIdBySubBlockId[parent] ?? parent) + .filter((key) => key !== anchor.parentContextKey && isSelectorContextKey(key)) + ), + ] // Carry the selector's static `mimeType` filter (Drive/Sheets pickers) so the // modal selector loads the same filtered list the editor would, not all files. const dependentContext = @@ -239,6 +234,7 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { selectorKey: dependent.selectorKey, title: makeTitle(dependent), ...(toolName ? { toolName } : {}), + ...(dependencyScope ? { dependencyScope } : {}), // Source value, so the always-on listing pre-fills a stable parent's selector. // The diff route overlays the stored/target-draft value onto `currentValue`; // `sourceValue` stays the raw source reference (the copy-resolved parent's seed). @@ -317,7 +313,6 @@ export function collectForkDependentReconfigs( resolveTargetBlockId: resolveBlockId, makeSubBlockKey: (id) => id, makeTitle: (dependent) => dependent.title ?? dependent.id ?? '', - chaining: true, out, }) @@ -387,7 +382,7 @@ export function collectForkDependentReconfigs( makeSubBlockKey: (id) => `${toolInputKey}[${toolIndex}].${id}`, makeTitle: (dependent) => dependent.title ?? dependent.id ?? '', toolName: toolLabel, - chaining: false, + dependencyScope: `${toolInputKey}[${toolIndex}]`, paramVisibilityById, out, }) diff --git a/apps/sim/lib/api/contracts/workspace-fork.ts b/apps/sim/lib/api/contracts/workspace-fork.ts index c861a0995d6..c88ff0a0f7f 100644 --- a/apps/sim/lib/api/contracts/workspace-fork.ts +++ b/apps/sim/lib/api/contracts/workspace-fork.ts @@ -346,6 +346,12 @@ export const forkDependentReconfigSchema = z.object({ * `Gmail 1`). Absent for top-level block subblocks. */ toolName: z.string().optional(), + /** + * Stable scope for one nested tool instance (e.g. `tools[0]`). Dependency context and + * descendant invalidation never cross this boundary, even when two tools expose the same + * canonical parameter ids. Absent for top-level block subblocks, which share the block scope. + */ + dependencyScope: z.string().optional(), /** * The field's stored value (from the persisted mapping), so the always-on reconfigure listing * pre-fills the selector with what the user last set. Empty string when unset; for an edge