diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index 1864d99c10..f74a0e0409 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -126,10 +126,6 @@ export const PATCH = withRouteHandler( } } - if (workflowId && workflowId !== existingChat[0].workflowId) { - return createErrorResponse('Changing a chat deployment workflow is not supported', 400) - } - let encryptedPassword if (password) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx index 143596c8dd..ac71ed7921 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/search-replace/workflow-search-replace.tsx @@ -9,6 +9,7 @@ import { getWorkflowSearchDependentClears } from '@/lib/workflows/search-replace import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer' import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements' import { + dedupeOverlappingWorkflowSearchMatches, getCompatibleResourceReplacementOptions, getWorkflowSearchCompatibleResourceMatches, getWorkflowSearchMatchResourceGroupKey, @@ -197,7 +198,10 @@ export function WorkflowSearchReplace() { }) const hydratedMatches = useMemo( - () => allHydratedMatches.filter((match) => workflowSearchMatchMatchesQuery(match, query)), + () => + dedupeOverlappingWorkflowSearchMatches( + allHydratedMatches.filter((match) => workflowSearchMatchMatchesQuery(match, query)) + ), [allHydratedMatches, query] ) diff --git a/apps/sim/lib/workflows/search-replace/resources/resolvers.test.ts b/apps/sim/lib/workflows/search-replace/resources/resolvers.test.ts new file mode 100644 index 0000000000..8515a9741f --- /dev/null +++ b/apps/sim/lib/workflows/search-replace/resources/resolvers.test.ts @@ -0,0 +1,115 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { dedupeOverlappingWorkflowSearchMatches } from '@/lib/workflows/search-replace/resources/resolvers' +import type { WorkflowSearchMatch } from '@/lib/workflows/search-replace/types' + +function createMatch(overrides: Partial): WorkflowSearchMatch { + return { + id: 'match', + blockId: 'block-1', + blockName: 'Block', + blockType: 'function', + subBlockId: 'code', + canonicalSubBlockId: 'code', + subBlockType: 'code', + valuePath: [], + target: { kind: 'subblock' }, + kind: 'text', + rawValue: '', + searchText: '', + editable: true, + navigable: true, + protected: false, + ...overrides, + } +} + +describe('dedupeOverlappingWorkflowSearchMatches', () => { + it('keeps the narrower text hit when a partial literal query overlaps an inline reference', () => { + const textMatch = createMatch({ + id: 'text-partial', + kind: 'text', + rawValue: ''", + range: { start: 8, end: 16 }, + }) + const referenceMatch = createMatch({ + id: 'workflow-reference', + kind: 'workflow-reference', + rawValue: '', + searchText: 'start.hello', + range: { start: 8, end: 21 }, + resource: { kind: 'workflow-reference', token: '', key: 'start.hello' }, + }) + + expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([textMatch]) + }) + + it('keeps the inline reference when it covers the same span as a text hit', () => { + const textMatch = createMatch({ + id: 'text-full', + kind: 'text', + rawValue: '', + searchText: "return ''", + range: { start: 8, end: 21 }, + }) + const referenceMatch = createMatch({ + id: 'workflow-reference', + kind: 'workflow-reference', + rawValue: '', + searchText: 'start.hello', + range: { start: 8, end: 21 }, + resource: { kind: 'workflow-reference', token: '', key: 'start.hello' }, + }) + + expect(dedupeOverlappingWorkflowSearchMatches([textMatch, referenceMatch])).toEqual([ + referenceMatch, + ]) + }) + + it('uses kind priority rather than iteration order for equal-span non-text matches', () => { + const workflowReferenceMatch = createMatch({ + id: 'workflow-reference', + kind: 'workflow-reference', + rawValue: '{{API_KEY}}', + searchText: 'API_KEY', + range: { start: 0, end: 11 }, + resource: { kind: 'workflow-reference', token: '{{API_KEY}}', key: 'API_KEY' }, + }) + const environmentMatch = createMatch({ + id: 'environment', + kind: 'environment', + rawValue: '{{API_KEY}}', + searchText: 'API_KEY', + range: { start: 0, end: 11 }, + resource: { kind: 'environment', token: '{{API_KEY}}', key: 'API_KEY' }, + }) + + expect( + dedupeOverlappingWorkflowSearchMatches([workflowReferenceMatch, environmentMatch]) + ).toEqual([workflowReferenceMatch]) + expect( + dedupeOverlappingWorkflowSearchMatches([environmentMatch, workflowReferenceMatch]) + ).toEqual([workflowReferenceMatch]) + }) + + it('does not collapse matches from different fields', () => { + const firstMatch = createMatch({ + id: 'first', + range: { start: 0, end: 4 }, + valuePath: ['first'], + }) + const secondMatch = createMatch({ + id: 'second', + range: { start: 0, end: 4 }, + valuePath: ['second'], + }) + + expect(dedupeOverlappingWorkflowSearchMatches([firstMatch, secondMatch])).toEqual([ + firstMatch, + secondMatch, + ]) + }) +}) diff --git a/apps/sim/lib/workflows/search-replace/resources/resolvers.ts b/apps/sim/lib/workflows/search-replace/resources/resolvers.ts index 9b78b4d8aa..d565d91b58 100644 --- a/apps/sim/lib/workflows/search-replace/resources/resolvers.ts +++ b/apps/sim/lib/workflows/search-replace/resources/resolvers.ts @@ -1,10 +1,27 @@ import type { WorkflowSearchMatch, + WorkflowSearchMatchKind, WorkflowSearchReplacementOption, WorkflowSearchResourceMeta, + WorkflowSearchValuePath, } from '@/lib/workflows/search-replace/types' import type { SelectorContext } from '@/hooks/selectors/types' +const OVERLAPPING_MATCH_KIND_PRIORITY: Record = { + text: 0, + environment: 1, + 'workflow-reference': 2, + 'oauth-credential': 3, + 'knowledge-base': 3, + 'knowledge-document': 3, + workflow: 3, + 'mcp-server': 3, + 'mcp-tool': 3, + table: 3, + file: 3, + 'selector-resource': 3, +} + export function stableStringifyWorkflowSearchValue(value: unknown): string { if (!value || typeof value !== 'object') return JSON.stringify(value) if (Array.isArray(value)) { @@ -88,6 +105,73 @@ export function getWorkflowSearchCompatibleResourceMatches( ) } +function searchValuePathKey(path: WorkflowSearchValuePath): string { + return path.map((segment) => `${typeof segment}:${String(segment)}`).join('/') +} + +function getRangeMatchScopeKey(match: WorkflowSearchMatch): string | null { + if (!match.range) return null + if (match.target.kind !== 'subblock') return null + return [match.blockId, match.subBlockId, searchValuePathKey(match.valuePath)].join(':') +} + +function rangesOverlap( + left: NonNullable, + right: NonNullable +): boolean { + return left.start < right.end && right.start < left.end +} + +function getRangeLength(match: WorkflowSearchMatch): number { + return match.range ? match.range.end - match.range.start : Number.POSITIVE_INFINITY +} + +function shouldPreferOverlappingMatch( + candidate: WorkflowSearchMatch, + current: WorkflowSearchMatch +): boolean { + const candidateLength = getRangeLength(candidate) + const currentLength = getRangeLength(current) + if (candidateLength !== currentLength) return candidateLength < currentLength + + const candidatePriority = OVERLAPPING_MATCH_KIND_PRIORITY[candidate.kind] + const currentPriority = OVERLAPPING_MATCH_KIND_PRIORITY[current.kind] + if (candidatePriority !== currentPriority) return candidatePriority > currentPriority + + return false +} + +export function dedupeOverlappingWorkflowSearchMatches( + matches: T[] +): T[] { + const deduped: T[] = [] + + for (const match of matches) { + const scopeKey = getRangeMatchScopeKey(match) + const matchRange = match.range + const existingIndex = + scopeKey && matchRange + ? deduped.findIndex( + (candidate) => + getRangeMatchScopeKey(candidate) === scopeKey && + candidate.range && + rangesOverlap(candidate.range, matchRange) + ) + : -1 + + if (existingIndex === -1) { + deduped.push(match) + continue + } + + if (shouldPreferOverlappingMatch(match, deduped[existingIndex])) { + deduped[existingIndex] = match + } + } + + return deduped +} + export function workflowSearchMatchMatchesQuery( match: WorkflowSearchMatch & { displayLabel?: string }, query: string,