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.
This commit is contained in:
Vikhyath Mondreti
2026-08-17 13:00:24 -07:00
committed by GitHub
parent 2732ab71e9
commit 5d172b4e94
3 changed files with 190 additions and 4 deletions
@@ -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 <api1.output> and <deletedblock.output>, then loop <loop.index>.'
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([
'<api1.output>',
])
})
it('still matches a reference by the token as stored', () => {
expect(findReferenceMatches('api1').map((match) => match.rawValue)).toEqual(['<api1.output>'])
})
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('<api1.output>')
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 <api1.output> and <deletedblock.output>, then loop <loop.index>.'
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 <hunterio1.email>.'
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()
@@ -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<string, string>
}) {
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,
@@ -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<string, { name?: string }>
): Map<string, string> {
const namesByPrefix = new Map<string, string>()
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
* `<sendemail.content>`. 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, string>
): 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<SubBlockConfig, 'type' | 'serviceId' | 'selectorKey' | 'requiredScopes'>,