diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index 8b8509107fc..15039e4f686 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -127,6 +127,113 @@ describe('indexWorkflowSearchMatches', () => { expect(matches.some((match) => match.target.kind === 'block-name')).toBe(false) }) + describe('block references search under the name the canvas shows', () => { + /** + * The panel's own pipeline: index everything, then keep what the query + * matches. Block references resolve no label of their own, so they reach the + * filter with `displayLabel` fallen back to the raw token, as the hydration + * hook leaves them. + */ + function findReferenceMatches(query: string) { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['agent-1'].subBlocks.systemPrompt.value = + 'Summarize and , then loop .' + + return indexWorkflowSearchMatches({ + workflow, + query, + mode: 'all', + includeResourceMatchesWithoutQuery: true, + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + .filter((match) => match.kind === 'workflow-reference') + .filter((match) => + workflowSearchMatchMatchesQuery({ ...match, displayLabel: match.rawValue }, query) + ) + } + + it('matches a reference by the spaced block name', () => { + expect(findReferenceMatches('API 1').map((match) => match.rawValue)).toEqual([ + '', + ]) + }) + + it('still matches a reference by the token as stored', () => { + expect(findReferenceMatches('api1').map((match) => match.rawValue)).toEqual(['']) + }) + + it('reads the resolved name back as the block is titled', () => { + const [match] = findReferenceMatches('API 1') + + expect(match.searchText).toBe('API 1.output') + expect(match.rawValue).toBe('') + expect(match.range).toEqual({ start: 10, end: 23 }) + }) + + it('leaves a prefix that names no block as written', () => { + const matches = indexWorkflowSearchMatches({ + workflow: (() => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['agent-1'].subBlocks.systemPrompt.value = + 'Summarize and , then loop .' + return workflow + })(), + mode: 'all', + includeResourceMatchesWithoutQuery: true, + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect( + matches + .filter((match) => match.kind === 'workflow-reference') + .map((match) => match.searchText) + ).toEqual(['API 1.output', 'deletedblock.output', 'loop.index']) + }) + + it('leaves an environment reference keyed by its variable name', () => { + const matches = indexWorkflowSearchMatches({ + workflow: createSearchReplaceWorkflowFixture(), + mode: 'all', + includeResourceMatchesWithoutQuery: true, + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect( + matches.filter((match) => match.kind === 'environment').map((match) => match.searchText) + ).toEqual(['OLD_SECRET', 'OLD_SECRET']) + }) + + /** + * Legacy workflows can hold two names that collide only now that + * `normalizeName` strips dots. `BlockResolver` gives the key to the dot-free + * name whichever order the blocks arrive in, so search has to name the same + * block or it would label the reference with a title that block does not own + * at execution time. + */ + it.each([ + ['dotted first', ['Hunter.io 1', 'Hunterio 1']], + ['dot-free first', ['Hunterio 1', 'Hunter.io 1']], + ])('names a legacy dot collision after the dot-free block (%s)', (_order, names) => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['knowledge-1'].name = names[0] + workflow.blocks['api-1'].name = names[1] + workflow.blocks['agent-1'].subBlocks.systemPrompt.value = 'Read .' + + const matches = indexWorkflowSearchMatches({ + workflow, + mode: 'all', + includeResourceMatchesWithoutQuery: true, + blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS, + }) + + expect( + matches + .filter((match) => match.kind === 'workflow-reference') + .map((match) => match.searchText) + ).toEqual(['Hunterio 1.email']) + }) + }) + it('does not index internal row metadata in structured subblock values', () => { const workflow = createSearchReplaceWorkflowFixture() diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 5b5dccf4307..25c2e3ca7b3 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -10,10 +10,12 @@ import { shouldParseSerializedSubBlockValue, } from '@/lib/workflows/search-replace/json-value-fields' import { + buildBlockNamesByReferencePrefix, getResourceKindForSubBlock, matchesSearchText, parseInlineReferences, parseStructuredResourceReferences, + resolveInlineReferenceSearchText, } from '@/lib/workflows/search-replace/resources' import { getWorkflowSearchSubflowFields } from '@/lib/workflows/search-replace/subflow-fields' import type { @@ -937,6 +939,7 @@ function addToolInputMatches({ blockConfigs, customTools, mcpToolNamesById, + blockNamesByReferencePrefix, }: { matches: WorkflowSearchMatch[] block: WorkflowSearchBlockState @@ -958,6 +961,7 @@ function addToolInputMatches({ blockConfigs?: WorkflowSearchIndexerOptions['blockConfigs'] customTools?: WorkflowSearchIndexerOptions['customTools'] mcpToolNamesById?: WorkflowSearchIndexerOptions['mcpToolNamesById'] + blockNamesByReferencePrefix: ReadonlyMap }) { const parentCanonicalModes = getSearchCanonicalModes(block) @@ -1058,7 +1062,11 @@ function addToolInputMatches({ for (const leaf of getSearchableStringLeaves(paramValue, subBlockType, 'reference')) { const inlineReferences = parseInlineReferences(leaf.value) inlineReferences.forEach((reference, referenceIndex) => { - const searchable = `${reference.rawValue} ${reference.searchText}` + const searchText = resolveInlineReferenceSearchText( + reference, + blockNamesByReferencePrefix + ) + const searchable = `${reference.rawValue} ${reference.searchText} ${searchText}` if ( !includeResourceMatchesWithoutQuery && !matchesSearchText(searchable, query, caseSensitive) @@ -1088,7 +1096,7 @@ function addToolInputMatches({ target: { kind: 'subblock' }, kind: reference.kind, rawValue: reference.rawValue, - searchText: reference.searchText, + searchText, range: reference.range, dependentValuePaths: nestedDependentValuePaths, resource: reference.resource, @@ -1250,6 +1258,7 @@ export function indexWorkflowSearchMatches( const matches: WorkflowSearchMatch[] = [] const resourceQueryEnabled = includeResourceMatchesWithoutQuery || Boolean(query) + const blockNamesByReferencePrefix = buildBlockNamesByReferencePrefix(workflow.blocks) for (const block of Object.values(workflow.blocks)) { const blockConfig = blockConfigs[block.type] ?? getBlock(block.type) @@ -1383,6 +1392,7 @@ export function indexWorkflowSearchMatches( blockConfigs, customTools, mcpToolNamesById, + blockNamesByReferencePrefix, }) continue } @@ -1471,7 +1481,11 @@ export function indexWorkflowSearchMatches( for (const leaf of referenceLeaves) { const inlineReferences = parseInlineReferences(leaf.value) inlineReferences.forEach((reference, referenceIndex) => { - const searchable = `${reference.rawValue} ${reference.searchText}` + const searchText = resolveInlineReferenceSearchText( + reference, + blockNamesByReferencePrefix + ) + const searchable = `${reference.rawValue} ${reference.searchText} ${searchText}` if ( !includeResourceMatchesWithoutQuery && !matchesSearchText(searchable, query, caseSensitive) @@ -1499,7 +1513,7 @@ export function indexWorkflowSearchMatches( target: { kind: 'subblock' }, kind: reference.kind, rawValue: reference.rawValue, - searchText: reference.searchText, + searchText, range: reference.range, resource: reference.resource, editable, diff --git a/apps/sim/lib/workflows/search-replace/resources/references.ts b/apps/sim/lib/workflows/search-replace/resources/references.ts index ad2619f9e66..2f7b053e746 100644 --- a/apps/sim/lib/workflows/search-replace/resources/references.ts +++ b/apps/sim/lib/workflows/search-replace/resources/references.ts @@ -8,6 +8,7 @@ import type { WorkflowSearchResourceMeta, } from '@/lib/workflows/search-replace/types' import type { SubBlockConfig } from '@/blocks/types' +import { normalizeName, REFERENCE } from '@/executor/constants' import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' import type { SelectorContext } from '@/hooks/selectors/types' @@ -67,6 +68,70 @@ export function parseInlineReferences(value: string): ParsedInlineReference[] { return references.sort((a, b) => a.range.start - b.range.start) } +/** + * Indexes a workflow's block names by the prefix their references carry, so a + * parsed reference can be read back as the name the canvas shows. + * + * Creating or renaming a block enforces uniqueness at the normalized level, but + * legacy workflows can still hold two names that collide only now that + * `normalizeName` strips dots. `BlockResolver` settles that tie by letting the + * dot-free name keep ownership of the key, so previously working references + * never change targets; this mirrors that rule rather than taking whichever + * block happens to be iterated last, so search names the block a reference + * actually resolves to at execution time. + * + * Blank names are skipped rather than mapped to an empty prefix. + */ +export function buildBlockNamesByReferencePrefix( + blocks: Record +): Map { + const namesByPrefix = new Map() + + for (const block of Object.values(blocks)) { + if (typeof block.name !== 'string') continue + const prefix = normalizeName(block.name) + if (!prefix) continue + + const incumbent = namesByPrefix.get(prefix) + if (incumbent === undefined || incumbent.includes(REFERENCE.PATH_DELIMITER)) { + namesByPrefix.set(prefix, block.name) + } + } + + return namesByPrefix +} + +/** + * Rewrites a block reference's search text into the name the block is shown + * under, so searching reads the same as the canvas does. + * + * A reference stores its target as `normalizeName(block.name)` - lowercased with + * whitespace and dots stripped - so a block headed "Send Email" is written + * ``. Searching the two words the card shows found the block + * itself but none of its references; only the run-together form found those. + * + * Only the prefix is rewritten. What follows it is the block's output path, not + * a name. A prefix that names no block - a system prefix like `loop`, or a + * reference left behind by a deleted block - is left exactly as written, and so + * is an environment reference, whose search text is its key rather than a name. + */ +export function resolveInlineReferenceSearchText( + reference: ParsedInlineReference, + blockNamesByReferencePrefix: ReadonlyMap +): string { + if (reference.kind !== 'workflow-reference') return reference.searchText + + const delimiterIndex = reference.searchText.indexOf(REFERENCE.PATH_DELIMITER) + const prefix = + delimiterIndex === -1 ? reference.searchText : reference.searchText.slice(0, delimiterIndex) + const blockName = blockNamesByReferencePrefix.get(normalizeName(prefix)) + if (!blockName || blockName === prefix) return reference.searchText + + return delimiterIndex === -1 + ? blockName + : `${blockName}${reference.searchText.slice(delimiterIndex)}` +} + export function parseStructuredResourceReferences( value: unknown, subBlockConfig?: Pick,