Skip to content

Commit 5d172b4

Browse files
fix(search): match block references by the name the canvas shows (#6779)
* fix(search): match block references by the name the canvas shows A block reference stores its target as the block's normalized name - lowercased with whitespace and dots stripped - so a block titled "Send Email" is written `<sendemail.content>`. Workflow search indexed that token as-is, so its searchable text never contained the block's actual name. Searching a name the way it reads on the card therefore found the block itself and none of its references, while the run-together form found the references and not the block. No single query could find both, and the run-together form is the one nothing in the UI ever shows. Resolve a reference's prefix back through the same helper that produced it, so the reference is searched under the name the block is titled with. `rawValue` is untouched, so the stored form keeps matching and the highlight and replace paths, which key off it, are unaffected. Environment references, system prefixes like `loop`, and references left behind by a deleted block resolve to no name and stay exactly as written. * fix(search): keep the dot-free name on a legacy reference-prefix collision 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. The prefix map took whichever block was iterated last instead, so search could name a reference after the dotted block while execution resolved it to the dot-free one - search reporting the wrong block, which is what this is meant to stop. Mirror the resolver's rule so both agree.
1 parent 2732ab7 commit 5d172b4

3 files changed

Lines changed: 190 additions & 4 deletions

File tree

apps/sim/lib/workflows/search-replace/indexer.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,113 @@ describe('indexWorkflowSearchMatches', () => {
127127
expect(matches.some((match) => match.target.kind === 'block-name')).toBe(false)
128128
})
129129

130+
describe('block references search under the name the canvas shows', () => {
131+
/**
132+
* The panel's own pipeline: index everything, then keep what the query
133+
* matches. Block references resolve no label of their own, so they reach the
134+
* filter with `displayLabel` fallen back to the raw token, as the hydration
135+
* hook leaves them.
136+
*/
137+
function findReferenceMatches(query: string) {
138+
const workflow = createSearchReplaceWorkflowFixture()
139+
workflow.blocks['agent-1'].subBlocks.systemPrompt.value =
140+
'Summarize <api1.output> and <deletedblock.output>, then loop <loop.index>.'
141+
142+
return indexWorkflowSearchMatches({
143+
workflow,
144+
query,
145+
mode: 'all',
146+
includeResourceMatchesWithoutQuery: true,
147+
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
148+
})
149+
.filter((match) => match.kind === 'workflow-reference')
150+
.filter((match) =>
151+
workflowSearchMatchMatchesQuery({ ...match, displayLabel: match.rawValue }, query)
152+
)
153+
}
154+
155+
it('matches a reference by the spaced block name', () => {
156+
expect(findReferenceMatches('API 1').map((match) => match.rawValue)).toEqual([
157+
'<api1.output>',
158+
])
159+
})
160+
161+
it('still matches a reference by the token as stored', () => {
162+
expect(findReferenceMatches('api1').map((match) => match.rawValue)).toEqual(['<api1.output>'])
163+
})
164+
165+
it('reads the resolved name back as the block is titled', () => {
166+
const [match] = findReferenceMatches('API 1')
167+
168+
expect(match.searchText).toBe('API 1.output')
169+
expect(match.rawValue).toBe('<api1.output>')
170+
expect(match.range).toEqual({ start: 10, end: 23 })
171+
})
172+
173+
it('leaves a prefix that names no block as written', () => {
174+
const matches = indexWorkflowSearchMatches({
175+
workflow: (() => {
176+
const workflow = createSearchReplaceWorkflowFixture()
177+
workflow.blocks['agent-1'].subBlocks.systemPrompt.value =
178+
'Summarize <api1.output> and <deletedblock.output>, then loop <loop.index>.'
179+
return workflow
180+
})(),
181+
mode: 'all',
182+
includeResourceMatchesWithoutQuery: true,
183+
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
184+
})
185+
186+
expect(
187+
matches
188+
.filter((match) => match.kind === 'workflow-reference')
189+
.map((match) => match.searchText)
190+
).toEqual(['API 1.output', 'deletedblock.output', 'loop.index'])
191+
})
192+
193+
it('leaves an environment reference keyed by its variable name', () => {
194+
const matches = indexWorkflowSearchMatches({
195+
workflow: createSearchReplaceWorkflowFixture(),
196+
mode: 'all',
197+
includeResourceMatchesWithoutQuery: true,
198+
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
199+
})
200+
201+
expect(
202+
matches.filter((match) => match.kind === 'environment').map((match) => match.searchText)
203+
).toEqual(['OLD_SECRET', 'OLD_SECRET'])
204+
})
205+
206+
/**
207+
* Legacy workflows can hold two names that collide only now that
208+
* `normalizeName` strips dots. `BlockResolver` gives the key to the dot-free
209+
* name whichever order the blocks arrive in, so search has to name the same
210+
* block or it would label the reference with a title that block does not own
211+
* at execution time.
212+
*/
213+
it.each([
214+
['dotted first', ['Hunter.io 1', 'Hunterio 1']],
215+
['dot-free first', ['Hunterio 1', 'Hunter.io 1']],
216+
])('names a legacy dot collision after the dot-free block (%s)', (_order, names) => {
217+
const workflow = createSearchReplaceWorkflowFixture()
218+
workflow.blocks['knowledge-1'].name = names[0]
219+
workflow.blocks['api-1'].name = names[1]
220+
workflow.blocks['agent-1'].subBlocks.systemPrompt.value = 'Read <hunterio1.email>.'
221+
222+
const matches = indexWorkflowSearchMatches({
223+
workflow,
224+
mode: 'all',
225+
includeResourceMatchesWithoutQuery: true,
226+
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
227+
})
228+
229+
expect(
230+
matches
231+
.filter((match) => match.kind === 'workflow-reference')
232+
.map((match) => match.searchText)
233+
).toEqual(['Hunterio 1.email'])
234+
})
235+
})
236+
130237
it('does not index internal row metadata in structured subblock values', () => {
131238
const workflow = createSearchReplaceWorkflowFixture()
132239

apps/sim/lib/workflows/search-replace/indexer.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import {
1010
shouldParseSerializedSubBlockValue,
1111
} from '@/lib/workflows/search-replace/json-value-fields'
1212
import {
13+
buildBlockNamesByReferencePrefix,
1314
getResourceKindForSubBlock,
1415
matchesSearchText,
1516
parseInlineReferences,
1617
parseStructuredResourceReferences,
18+
resolveInlineReferenceSearchText,
1719
} from '@/lib/workflows/search-replace/resources'
1820
import { getWorkflowSearchSubflowFields } from '@/lib/workflows/search-replace/subflow-fields'
1921
import type {
@@ -937,6 +939,7 @@ function addToolInputMatches({
937939
blockConfigs,
938940
customTools,
939941
mcpToolNamesById,
942+
blockNamesByReferencePrefix,
940943
}: {
941944
matches: WorkflowSearchMatch[]
942945
block: WorkflowSearchBlockState
@@ -958,6 +961,7 @@ function addToolInputMatches({
958961
blockConfigs?: WorkflowSearchIndexerOptions['blockConfigs']
959962
customTools?: WorkflowSearchIndexerOptions['customTools']
960963
mcpToolNamesById?: WorkflowSearchIndexerOptions['mcpToolNamesById']
964+
blockNamesByReferencePrefix: ReadonlyMap<string, string>
961965
}) {
962966
const parentCanonicalModes = getSearchCanonicalModes(block)
963967

@@ -1058,7 +1062,11 @@ function addToolInputMatches({
10581062
for (const leaf of getSearchableStringLeaves(paramValue, subBlockType, 'reference')) {
10591063
const inlineReferences = parseInlineReferences(leaf.value)
10601064
inlineReferences.forEach((reference, referenceIndex) => {
1061-
const searchable = `${reference.rawValue} ${reference.searchText}`
1065+
const searchText = resolveInlineReferenceSearchText(
1066+
reference,
1067+
blockNamesByReferencePrefix
1068+
)
1069+
const searchable = `${reference.rawValue} ${reference.searchText} ${searchText}`
10621070
if (
10631071
!includeResourceMatchesWithoutQuery &&
10641072
!matchesSearchText(searchable, query, caseSensitive)
@@ -1088,7 +1096,7 @@ function addToolInputMatches({
10881096
target: { kind: 'subblock' },
10891097
kind: reference.kind,
10901098
rawValue: reference.rawValue,
1091-
searchText: reference.searchText,
1099+
searchText,
10921100
range: reference.range,
10931101
dependentValuePaths: nestedDependentValuePaths,
10941102
resource: reference.resource,
@@ -1250,6 +1258,7 @@ export function indexWorkflowSearchMatches(
12501258

12511259
const matches: WorkflowSearchMatch[] = []
12521260
const resourceQueryEnabled = includeResourceMatchesWithoutQuery || Boolean(query)
1261+
const blockNamesByReferencePrefix = buildBlockNamesByReferencePrefix(workflow.blocks)
12531262

12541263
for (const block of Object.values(workflow.blocks)) {
12551264
const blockConfig = blockConfigs[block.type] ?? getBlock(block.type)
@@ -1383,6 +1392,7 @@ export function indexWorkflowSearchMatches(
13831392
blockConfigs,
13841393
customTools,
13851394
mcpToolNamesById,
1395+
blockNamesByReferencePrefix,
13861396
})
13871397
continue
13881398
}
@@ -1471,7 +1481,11 @@ export function indexWorkflowSearchMatches(
14711481
for (const leaf of referenceLeaves) {
14721482
const inlineReferences = parseInlineReferences(leaf.value)
14731483
inlineReferences.forEach((reference, referenceIndex) => {
1474-
const searchable = `${reference.rawValue} ${reference.searchText}`
1484+
const searchText = resolveInlineReferenceSearchText(
1485+
reference,
1486+
blockNamesByReferencePrefix
1487+
)
1488+
const searchable = `${reference.rawValue} ${reference.searchText} ${searchText}`
14751489
if (
14761490
!includeResourceMatchesWithoutQuery &&
14771491
!matchesSearchText(searchable, query, caseSensitive)
@@ -1499,7 +1513,7 @@ export function indexWorkflowSearchMatches(
14991513
target: { kind: 'subblock' },
15001514
kind: reference.kind,
15011515
rawValue: reference.rawValue,
1502-
searchText: reference.searchText,
1516+
searchText,
15031517
range: reference.range,
15041518
resource: reference.resource,
15051519
editable,

apps/sim/lib/workflows/search-replace/resources/references.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
WorkflowSearchResourceMeta,
99
} from '@/lib/workflows/search-replace/types'
1010
import type { SubBlockConfig } from '@/blocks/types'
11+
import { normalizeName, REFERENCE } from '@/executor/constants'
1112
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
1213
import type { SelectorContext } from '@/hooks/selectors/types'
1314

@@ -67,6 +68,70 @@ export function parseInlineReferences(value: string): ParsedInlineReference[] {
6768
return references.sort((a, b) => a.range.start - b.range.start)
6869
}
6970

71+
/**
72+
* Indexes a workflow's block names by the prefix their references carry, so a
73+
* parsed reference can be read back as the name the canvas shows.
74+
*
75+
* Creating or renaming a block enforces uniqueness at the normalized level, but
76+
* legacy workflows can still hold two names that collide only now that
77+
* `normalizeName` strips dots. `BlockResolver` settles that tie by letting the
78+
* dot-free name keep ownership of the key, so previously working references
79+
* never change targets; this mirrors that rule rather than taking whichever
80+
* block happens to be iterated last, so search names the block a reference
81+
* actually resolves to at execution time.
82+
*
83+
* Blank names are skipped rather than mapped to an empty prefix.
84+
*/
85+
export function buildBlockNamesByReferencePrefix(
86+
blocks: Record<string, { name?: string }>
87+
): Map<string, string> {
88+
const namesByPrefix = new Map<string, string>()
89+
90+
for (const block of Object.values(blocks)) {
91+
if (typeof block.name !== 'string') continue
92+
const prefix = normalizeName(block.name)
93+
if (!prefix) continue
94+
95+
const incumbent = namesByPrefix.get(prefix)
96+
if (incumbent === undefined || incumbent.includes(REFERENCE.PATH_DELIMITER)) {
97+
namesByPrefix.set(prefix, block.name)
98+
}
99+
}
100+
101+
return namesByPrefix
102+
}
103+
104+
/**
105+
* Rewrites a block reference's search text into the name the block is shown
106+
* under, so searching reads the same as the canvas does.
107+
*
108+
* A reference stores its target as `normalizeName(block.name)` - lowercased with
109+
* whitespace and dots stripped - so a block headed "Send Email" is written
110+
* `<sendemail.content>`. Searching the two words the card shows found the block
111+
* itself but none of its references; only the run-together form found those.
112+
*
113+
* Only the prefix is rewritten. What follows it is the block's output path, not
114+
* a name. A prefix that names no block - a system prefix like `loop`, or a
115+
* reference left behind by a deleted block - is left exactly as written, and so
116+
* is an environment reference, whose search text is its key rather than a name.
117+
*/
118+
export function resolveInlineReferenceSearchText(
119+
reference: ParsedInlineReference,
120+
blockNamesByReferencePrefix: ReadonlyMap<string, string>
121+
): string {
122+
if (reference.kind !== 'workflow-reference') return reference.searchText
123+
124+
const delimiterIndex = reference.searchText.indexOf(REFERENCE.PATH_DELIMITER)
125+
const prefix =
126+
delimiterIndex === -1 ? reference.searchText : reference.searchText.slice(0, delimiterIndex)
127+
const blockName = blockNamesByReferencePrefix.get(normalizeName(prefix))
128+
if (!blockName || blockName === prefix) return reference.searchText
129+
130+
return delimiterIndex === -1
131+
? blockName
132+
: `${blockName}${reference.searchText.slice(delimiterIndex)}`
133+
}
134+
70135
export function parseStructuredResourceReferences(
71136
value: unknown,
72137
subBlockConfig?: Pick<SubBlockConfig, 'type' | 'serviceId' | 'selectorKey' | 'requiredScopes'>,

0 commit comments

Comments
 (0)