fix(search): answer a Note match on the canvas card (#6901)

* fix(search): answer a Note match on the canvas card

A workflow search match inside a Note counted towards the result total and
then highlighted nowhere: the editor panel renders nothing for a Note, and
`clearCurrentBlock` — which the panel calls to refuse one — also cleared the
shared `activeSearchTarget`, destroying the very target the card was about to
paint. Searching a 15k-character note reported "1 of 6" and moved nothing.

The card's read view is now the surface that answers:

- A rehype plugin marks every rendered occurrence; the current one is picked by
  an ordinal counted with the same scan the indexer uses, and travels by context
  so cycling matches does not re-parse the document.
- `<Streamdown>` is keyed on the query. Its memo comparator ignores
  `rehypePlugins`/`components`, so a plugin change alone cannot re-render it —
  marks appeared only when something else remounted the card, and then outlived
  the query that produced them.
- The canvas selects, centres and expands the Note, because a compact card
  resets its scroll region to the top and cannot hold a position deep in its
  own body. Scrolling to the mark is `scrollTop` arithmetic, never
  `scrollIntoView`, which would drag ReactFlow's transformed viewport off-frame.
- Title matches mark the name too.

`activeSearchTarget` is re-published with a fresh identity on most of the search
panel's renders, so subscribers take primitives. Holding the object in
`WorkflowContent` — the panel's own ancestor — closed an unbounded update loop.

Separately, the serializer escaped every underscore, writing
`SB\_ACTION\_ROUTER\_SECRET` into the document. CommonMark's intraword rule
means that backslash carries no meaning, and search matches the stored markdown,
so it made anything with an underscore unfindable in a note that plainly showed
it. Dropped outside code regions, where the serializer emits verbatim and a
backslash is the author's own character.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(search): honour fences and folded whitespace in Note highlighting

Two review findings, both real.

The intraword-underscore cleanup guarded code with a pattern that recognised
only the shortest delimiter forms — a bare ``` pair and a single-backtick span.
A ````-fenced block, a tilde fence, or a ``multi-backtick`` span ended the
region early and handed the rest of the author's code to the rewrite, turning
`a\_b` into `a_b` inside their code sample. Fenced blocks are now walked a line
at a time, tracking the opening delimiter exactly the way stripEmptyListItemLines
already does (three or more, closed only by a run at least as long), and the
inline branch matches a backtick RUN closed by one of equal length.

The note scanner claimed to be the same scan as the indexer's `findTextRanges`
but did not fold whitespace, which staging added since this branch was written.
The indexer folds every `\s` to a space, so a phrase matches across a soft line
break — which `remark-breaks` renders as a `<br>`, splitting the phrase over two
text nodes that a per-node scan could never see. The hit counted in the panel
and highlighted nowhere, the exact bug this branch exists to fix.

The plugin now scans runs of continuously-readable text rather than single
nodes, so a match spanning an inline boundary (a soft break, a bold word) is
wrapped as several marks sharing one ordinal. Runs end at any non-inline
element, so two paragraphs are never joined into a phrase the reader cannot see.

`foldSearchWhitespace` moved to `@sim/utils/string`: the canvas card renders
from a package, which cannot import from `apps/*`, and two copies of that rule
silently disagreeing is precisely what produced the second finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(markdown): close a fence only on a bare delimiter run

A closing fence carries nothing but its delimiter run; a line that merely
starts with one is content. The guard matched the prefix alone, so an interior
line like ` ````example ` inside a same-length fence ended the block, and every
cleanup below then processed the author's remaining code as prose — dropping
the backslashes from their `a\_b`.

Both fence walks in this file shared that flaw, so both now go through one
`closesFence`, which requires the run to be followed by nothing but whitespace.
Strictly more conservative: a fence stays open longer, so more content is left
verbatim.

Scope, stated plainly: the serializer always opens a block with one more
delimiter than the longest run inside it, so its own output cannot reach this
shape today, and `postProcessSerializedMarkdown` only ever sees serializer
output. This is a correctness fix that removes an unstated coupling to that
choice, not a live corruption path. The tests therefore exercise
`postProcessSerializedMarkdown` directly — a round-trip test of the same input
would pass either way, which is exactly the vacuous check worth avoiding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(markdown): skip quoted fences, and stop joining runs across inline tags

Two review findings, both real, both the same shape: a rule that looked at the
rendered form and forgot what the source actually says.

QUOTED FENCES. The fence walk only recognised a bare delimiter run, but the
serializer writes a fence inside a blockquote or a `[!NOTE]` callout with a `>`
on every line. Code state was therefore never entered there and the block's
interior was cleaned up as prose: `> x = a\_b` round-tripped to `> x = a_b`,
losing the author's backslash. Unlike the fence-length cases this one is
reachable today — verified against the real serializer before and after. Both
fence walks now unquote the line first.

INLINE JOINS. Runs concatenated the visible text of every inline tag, so
`a<strong>b</strong>c` read as `abc` — a hit that cannot exist in the markdown
the indexer scans, where `**` sits between the words. That is worse than a
spurious mark: `occurrenceIndex` counts SOURCE occurrences, so a fabricated hit
earlier in the document steals the current ordinal and paints the mark on text
the search never matched. Only `<br>` continues a run now, because it alone
stands for a character the source really has (a `\n`, folded to a space).
Everything else stands for syntax the render drops.

Nothing real is lost: a match spanning `a**b**c` would have to contain the
asterisks to exist at all, and a match wholly inside an element is still found —
the element simply starts its own run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(search): match a Note body as it renders, instead of rewriting the file

Replaces the serializer change with one that writes nothing.

The editor backslash-escapes every markdown-significant character in prose, so a
Note the reader sees as `{{TE_SERET}}` is stored as `{{TE\_SERET}}` and search —
which matches the stored value — could not find it. The previous approach undid
that escape in `postProcessSerializedMarkdown`, which meant re-deriving markdown
structure from the serialized string with regexes so it knew what was code. That
is a losing game: three review rounds, each finding another construct it did not
model (longer fences, then delimiter-prefixed lines, then quoted fences), and
each miss REWROTE somebody's code. `markdown-fidelity.ts` is back to staging,
byte for byte.

The escape is now undone on the matching side only. A field declares
`searchTextFormat: 'markdown'` (the Note body is the only one), and the indexer
matches it against `projectEscapedMarkdownForSearch(value)` — a total,
structure-free function that returns the rendered text plus an index back into
the source. Ranges stay in source coordinates, so replace still rewrites the
whole `\_` and never strands a backslash.

The asymmetry is the whole point: a matcher that de-escapes something a fence
would have kept literal changes only which text highlights, and no caller writes
it back. A rewriter making the identical mistake corrupts the file. So there is
nothing here that needs to know about fences at all.

Two consequences worth having: existing notes are searchable immediately rather
than after their next edit, and no stored byte changes, so no document the
editor has ever written can be affected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vikhyath Mondreti
2026-08-20 15:29:25 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent a27f376164
commit 2e111f615c
19 changed files with 1565 additions and 34 deletions
@@ -1,16 +1,21 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
BLOCK_DIMENSIONS,
countNoteSearchOccurrencesBefore,
DEFAULT_NOTE_COLOR,
estimateNoteBlockHeight,
forEachNoteSourceOccurrence,
getNoteStringValue,
isNoteColor,
NoteBlockView,
type NoteColor,
type NoteContentEditorProps,
type NoteSearchHighlight,
type NoteSearchRange,
} from '@sim/workflow-renderer'
import dynamic from 'next/dynamic'
import { type NodeProps, useReactFlow } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import { appendNoteImageMarkdown } from '@/lib/workflows/notes/add-image'
import {
NOTE_ADD_IMAGE_EVENT,
@@ -26,7 +31,7 @@ import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]
import { isBlockProtected } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { useIsCurrentWorkflowExecuting } from '@/stores/execution'
import { usePanelEditorStore } from '@/stores/panel'
import { usePanelEditorSearchStore, usePanelEditorStore } from '@/stores/panel'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -48,6 +53,9 @@ const NoteMarkdownEditor = dynamic(
const NOTE_EXPAND_FOCUS_DURATION_MS = 300
/** The markdown body's sub-block id, as declared by the Note block config. */
const NOTE_CONTENT_SUBBLOCK_ID = 'content'
function renderNoteContentEditor(props: NoteContentEditorProps) {
return <NoteMarkdownEditor {...props} />
}
@@ -106,7 +114,92 @@ export const NoteBlock = memo(function NoteBlock({
useCallback((state) => isBlockProtected(id, state.blocks), [id])
)
const clearCurrentBlock = usePanelEditorStore((state) => state.clearCurrentBlock)
/* Flattened to primitives under a shallow compare, never held as the target
object: the search panel re-publishes an equal target on most of its own
renders, and a card that subscribes to the object rebuilds its highlight on
every one of them. */
const searchTarget = usePanelEditorSearchStore(
useShallow((state) => {
const target = state.activeSearchTarget
return {
blockId: target?.blockId ?? null,
subBlockId: target?.subBlockId ?? null,
targetKind: target?.targetKind ?? null,
query: target?.query ?? null,
rawValue: target?.rawValue ?? null,
rangeStart: target?.range?.start ?? null,
rangeEnd: target?.range?.end ?? null,
}
})
)
const canEditNote = canEditWorkflow && !data.isPreview && !isProtected
/** Whether the active search match belongs to this card at all. */
const isSearchTargetBlock =
!data.isPreview &&
!data.isEmbedded &&
searchTarget.blockId === id &&
Boolean(searchTarget.query)
/**
* The workflow search match this card should paint in its body.
*
* The panel editor renders nothing for a note, so the card is the only
* surface that can answer a search — without this a note match counts towards
* "1 of 6" and then highlights nowhere.
*
* The stored range is re-checked against the live content before it is
* trusted: the index runs over a snapshot, and a collaborator's edit between
* indexing and painting would shift the ordinal onto the wrong words. When it
* no longer holds — or the match never carried one — this falls back to the
* first rendered occurrence, which is the same fallback the editor panel
* makes for a label whose stored range does not fit.
*/
const searchHighlight = useMemo<NoteSearchHighlight | null>(() => {
if (!isSearchTargetBlock) return null
if (searchTarget.targetKind !== 'subblock') return null
if (searchTarget.subBlockId !== NOTE_CONTENT_SUBBLOCK_ID) return null
const { query, rawValue, rangeStart, rangeEnd } = searchTarget
if (!query) return null
const rangeHolds =
rangeStart !== null && rangeEnd !== null && content.slice(rangeStart, rangeEnd) === rawValue
if (rangeHolds) {
return {
query,
occurrenceIndex: countNoteSearchOccurrencesBefore(content, query, rangeStart),
}
}
/* Asked through the same scan that will do the marking, rather than a local
`includes`: a bare comparison skips the whitespace fold, so a query the
indexer matched across a newline or a non-breaking space would read as
absent here and the card would paint nothing while the panel counted it. */
let occurs = false
forEachNoteSourceOccurrence(content, query, () => {
occurs = true
})
return occurs ? { query, occurrenceIndex: 0 } : null
}, [content, isSearchTargetBlock, searchTarget])
/**
* The match to paint in the title, for a search that hit the note's name.
*
* A name match carries an exact range over `block.name`, so unlike the body
* there is no occurrence to reconstruct — it is used directly, once it still
* describes the live name.
*/
const nameSearchRange = useMemo<NoteSearchRange | null>(() => {
if (!isSearchTargetBlock) return null
if (searchTarget.targetKind !== 'block-name') return null
const { rawValue, rangeStart, rangeEnd } = searchTarget
if (rangeStart === null || rangeEnd === null) return null
if ((name ?? '').slice(rangeStart, rangeEnd) !== rawValue) return null
return { start: rangeStart, end: rangeEnd }
}, [isSearchTargetBlock, name, searchTarget])
const uploadNoteImage = useNoteImageUpload()
const imageInputRef = useRef<HTMLInputElement>(null)
const [blockHeight, setBlockHeight] = useState(() => estimateNoteBlockHeight(content))
@@ -132,6 +225,48 @@ export const NoteBlock = memo(function NoteBlock({
[]
)
const isExpandedRef = useRef(isExpanded)
useEffect(() => {
isExpandedRef.current = isExpanded
}, [isExpanded])
/**
* Opens the card while it holds the current search match, and closes it again
* when the match moves on.
*
* A compact note shows a few lines of what is often a long document, so a
* match found deep inside it lands in a body the user cannot read. Expanding
* is the same gesture a click makes, and it gives the mark somewhere to be.
*
* Two things this deliberately does not do. It does not call
* {@link handleExpandedChange}, whose own `setCenter` would race the camera
* the canvas already moved for this match. And it only closes a card it
* opened — a note the user expanded by hand, or collapsed by hand while the
* match still points here, is left exactly as they left it.
*/
const searchExpandedRef = useRef(false)
const hasSearchMatch = searchHighlight !== null || nameSearchRange !== null
useEffect(() => {
/* Losing edit rights already force-collapses the card during render. Drop the latch with it,
or regaining them would hit the early return below and leave a deep match clipped in the
compact body until the active match moved. */
if (!canEditNote) {
searchExpandedRef.current = false
return
}
if (hasSearchMatch) {
if (searchExpandedRef.current || isExpandedRef.current) return
searchExpandedRef.current = true
setIsExpanded(true)
return
}
if (!searchExpandedRef.current) return
searchExpandedRef.current = false
setIsExpanded(false)
}, [canEditNote, hasSearchMatch])
const handleNameChange = (nextName: string) => {
if (!canEditNote) return false
return collaborativeUpdateBlockName(id, nextName).success
@@ -139,7 +274,7 @@ export const NoteBlock = memo(function NoteBlock({
const handleContentChange = (nextContent: string) => {
if (!canEditNote) return
collaborativeSetSubblockValue(id, 'content', nextContent)
collaborativeSetSubblockValue(id, NOTE_CONTENT_SUBBLOCK_ID, nextContent)
}
/**
@@ -175,9 +310,14 @@ export const NoteBlock = memo(function NoteBlock({
if (!image) continue
/* Re-read per image: each append has to build on the previous one, and on
anything a collaborator wrote while the upload was in flight. */
const current = getNoteStringValue(useSubBlockStore.getState().getValue(id, 'content')) ?? ''
const current =
getNoteStringValue(useSubBlockStore.getState().getValue(id, NOTE_CONTENT_SUBBLOCK_ID)) ?? ''
setExternalContentWrites((count) => count + 1)
collaborativeSetSubblockValue(id, 'content', appendNoteImageMarkdown(current, image))
collaborativeSetSubblockValue(
id,
NOTE_CONTENT_SUBBLOCK_ID,
appendNoteImageMarkdown(current, image)
)
}
}
@@ -292,6 +432,8 @@ export const NoteBlock = memo(function NoteBlock({
onExpandedChange={handleExpandedChange}
onImageFilesDrop={(files) => void insertImages(files)}
renderContentEditor={renderNoteContentEditor}
searchHighlight={searchHighlight}
nameSearchRange={nameSearchRange}
actionBar={
<ActionBar
blockId={id}
@@ -145,7 +145,7 @@ import {
} from '@/stores/execution'
import { useSearchModalStore } from '@/stores/modals/search/store'
import type { PendingConnect } from '@/stores/modals/search/types'
import { usePanelEditorStore, usePanelStore } from '@/stores/panel'
import { usePanelEditorSearchStore, usePanelEditorStore, usePanelStore } from '@/stores/panel'
import { useUndoRedoStore } from '@/stores/undo-redo'
import { useVariablesModalStore } from '@/stores/variables/modal'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
@@ -4608,6 +4608,73 @@ const WorkflowContent = React.memo(
return () => window.removeEventListener('keydown', handleArrowNavigation, true)
}, [embedded, getNodes, blocks, focusBlockInView])
/**
* Brings a Note holding the current search match onto the canvas.
*
* Every other block answers a search through the editor panel, which scrolls
* the matching field into view for free. A Note renders nothing there — the
* card itself is the surface — so the camera has to do that job here, and
* the card has to be selected before it will hold a scroll position deep in
* its own body rather than snapping back to the top.
*
* Keyed on the match rather than the block, so cycling between two matches
* in one Note re-asserts a camera that is already where it needs to be
* (visually inert) instead of stranding the second match off-screen after
* the user has panned away. Matches on every other kind of block are marked
* handled and otherwise left alone — without that, walking away to a block
* match and back to a Note one would read as the same match twice and skip
* the camera the second time.
*
* Also covers a match on the Note's *name*, which the card cannot underline
* but which at least lands the user on the right card.
*
* Subscribed as two ids and NEVER as the target object. The search panel
* renders inside this component and re-publishes an equal target on most of
* its own renders (its hydration hooks hand back fresh arrays), so holding
* the object here re-renders the panel, whose effect re-publishes, which
* re-renders it again — an unbounded update loop the moment a search opens.
* Two string selectors are compared by value, so a re-publish of the same
* match is inert.
*/
const searchMatchId = usePanelEditorSearchStore(
(state) => state.activeSearchTarget?.matchId ?? null
)
const searchMatchBlockId = usePanelEditorSearchStore(
(state) => state.activeSearchTarget?.blockId ?? null
)
const focusedSearchMatchIdRef = useRef<string | null>(null)
useEffect(() => {
if (embedded) return
if (!searchMatchId || !searchMatchBlockId) {
focusedSearchMatchIdRef.current = null
return
}
if (searchMatchId === focusedSearchMatchIdRef.current) return
if (blocks[searchMatchBlockId]?.type !== 'note') {
focusedSearchMatchIdRef.current = searchMatchId
return
}
/* Read from `displayNodes` rather than `getNodes()` so a match that
arrives before its node has mounted is retried on the commit that
mounts it, instead of being dropped. */
const node = displayNodes.find((candidate) => candidate.id === searchMatchBlockId)
if (!node) return
focusedSearchMatchIdRef.current = searchMatchId
setDisplayNodes((currentNodes) =>
resolveSelectionConflicts(
currentNodes.map((currentNode) => ({
...currentNode,
selected: currentNode.id === node.id,
})),
blocks
)
)
focusBlockInView(node)
}, [blocks, displayNodes, embedded, focusBlockInView, searchMatchBlockId, searchMatchId])
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
const onEdgeClick = useCallback(
(event: React.MouseEvent, edge: any) => {
+1
View File
@@ -15,6 +15,7 @@ export const NoteBlock: BlockConfig = {
{
id: 'content',
type: 'long-input',
searchTextFormat: 'markdown',
rows: 8,
placeholder: 'Add context or instructions for collaborators...',
description: 'Write your note using Markdown. YouTube links will display as embedded videos.',
+13
View File
@@ -269,6 +269,19 @@ export interface SubBlockConfig {
type: SubBlockType
mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced' // Default is 'both' if not specified. 'trigger' means only shown in trigger mode. 'trigger-advanced' is the advanced side of a trigger field — either a canonical pair member or a standalone field shown under the block-level advanced toggle
canonicalParamId?: string
/**
* Declares that the stored value is markdown, so workflow search matches it
* against the text it RENDERS as rather than its source.
*
* The rich-text editor backslash-escapes every markdown-significant character
* in prose, so a Note body the reader sees as `SB_ACTION` is stored as
* `SB\_ACTION` and would otherwise be unfindable by what is on screen. Ranges
* stay in source coordinates, so replace still rewrites the escaped span.
*
* Omit for every ordinary field: a code or plain-text value is searched as
* stored, where a backslash is the author's own character.
*/
searchTextFormat?: 'markdown'
/** Controls parameter visibility in agent/tool-input context */
paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden'
/**
@@ -167,6 +167,79 @@ describe('indexWorkflowSearchMatches', () => {
expect(matches.some((match) => match.target.kind === 'block-name')).toBe(false)
})
describe('a markdown field is searched as it renders', () => {
/*
* The rich-text editor backslash-escapes every markdown-significant character in prose, so a
* Note the reader sees as `{{TE_SERET}}` is stored as `{{TE\_SERET}}`. Searching what is on
* screen has to see through that, and the range has to keep spanning the escaped source so
* replace rewrites the whole `\_` instead of stranding the backslash.
*/
const NOTE_CONFIGS = {
note: { subBlocks: [{ id: 'content', type: 'long-input', searchTextFormat: 'markdown' }] },
function: { subBlocks: [{ id: 'code', type: 'code' }] },
} as unknown as typeof SEARCH_REPLACE_BLOCK_CONFIGS
function workflowWith(noteContent: string, code: string) {
return {
blocks: {
'note-1': {
id: 'note-1',
type: 'note',
name: 'Note',
position: { x: 0, y: 0 },
enabled: true,
horizontalHandles: true,
subBlocks: { content: { id: 'content', type: 'long-input', value: noteContent } },
outputs: {},
},
'fn-1': {
id: 'fn-1',
type: 'function',
name: 'Fn',
position: { x: 0, y: 0 },
enabled: true,
horizontalHandles: true,
subBlocks: { code: { id: 'code', type: 'code', value: code } },
outputs: {},
},
},
} as unknown as Parameters<typeof indexWorkflowSearchMatches>[0]['workflow']
}
it('finds an escaped underscore by what the reader sees', () => {
const matches = indexWorkflowSearchMatches({
workflow: workflowWith('{{TE\\_SERET}}', ''),
query: '{{TE_',
mode: 'text',
blockConfigs: NOTE_CONFIGS,
})
expect(matches.map((match) => match.blockId)).toContain('note-1')
})
it('keeps the range over the escape, so replace cannot strand a backslash', () => {
const content = 'uses SB\\_ACTION here'
const [match] = indexWorkflowSearchMatches({
workflow: workflowWith(content, ''),
query: 'SB_ACTION',
mode: 'text',
blockConfigs: NOTE_CONFIGS,
})
expect(content.slice(match.range!.start, match.range!.end)).toBe('SB\\_ACTION')
expect(match.rawValue).toBe('SB\\_ACTION')
})
/* A code field stores what the author typed: a backslash there is theirs, not an escape. */
it('leaves a field that is not markdown searched as stored', () => {
const matches = indexWorkflowSearchMatches({
workflow: workflowWith('', 'const s = "a\\_b"'),
query: 'a_b',
mode: 'text',
blockConfigs: NOTE_CONFIGS,
})
expect(matches.map((match) => match.blockId)).not.toContain('fn-1')
})
})
describe('block references search under the name the canvas shows', () => {
/**
* The panel's own pipeline: index everything, then keep what the query
@@ -1,4 +1,5 @@
import { isRecordLike } from '@sim/utils/object'
import { foldSearchWhitespace, projectEscapedMarkdownForSearch } from '@sim/utils/string'
import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks'
import type { SubBlockType } from '@sim/workflow-types/blocks'
import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow'
@@ -10,7 +11,6 @@ import {
} from '@/lib/workflows/search-replace/json-value-fields'
import {
buildBlockNamesByReferencePrefix,
foldSearchWhitespace,
getResourceKindForSubBlock,
matchesSearchText,
parseInlineReferences,
@@ -67,15 +67,37 @@ function normalizeForSearch(value: string, caseSensitive: boolean): string {
return caseSensitive ? folded : folded.toLowerCase()
}
function findTextRanges(value: string, query: string, caseSensitive: boolean) {
/**
* Ranges of `query` in `value`, always in `value`'s own coordinates.
*
* A field declaring `searchTextFormat: 'markdown'` is matched against the text
* it RENDERS as: the rich-text editor backslash-escapes every
* markdown-significant character in prose, so a Note body reading `SB_ACTION`
* on screen is stored as `SB\_ACTION`. The escape is undone only to match — the
* returned range still spans the escaped source, so replace rewrites the whole
* `\_` and never strands a backslash.
*/
function findTextRanges(
value: string,
query: string,
caseSensitive: boolean,
searchTextFormat?: SubBlockConfig['searchTextFormat']
) {
if (!query) return []
const source = normalizeForSearch(value, caseSensitive)
const projection = searchTextFormat === 'markdown' ? projectEscapedMarkdownForSearch(value) : null
const source = normalizeForSearch(projection ? projection.text : value, caseSensitive)
const target = normalizeForSearch(query, caseSensitive)
const ranges: Array<{ start: number; end: number }> = []
let index = source.indexOf(target)
while (index !== -1) {
ranges.push({ start: index, end: index + target.length })
const end = index + target.length
ranges.push(
projection
? { start: projection.starts[index], end: projection.starts[end] }
: { start: index, end }
)
index = source.indexOf(target, index + Math.max(target.length, 1))
}
@@ -578,6 +600,8 @@ interface AddTextMatchesOptions {
protectedByLock: boolean
isSnapshotView: boolean
readonlyReason?: string
/** Declared by the field's config; see {@link findTextRanges}. */
searchTextFormat?: SubBlockConfig['searchTextFormat']
}
function getReadonlyReason({
@@ -610,8 +634,9 @@ function addTextMatches({
protectedByLock,
isSnapshotView,
readonlyReason,
searchTextFormat,
}: AddTextMatchesOptions) {
const ranges = query ? findTextRanges(value, query, caseSensitive) : []
const ranges = query ? findTextRanges(value, query, caseSensitive, searchTextFormat) : []
ranges.forEach((range, occurrenceIndex) => {
matches.push({
id: createMatchId([
@@ -1474,6 +1499,7 @@ export function indexWorkflowSearchMatches(
target: { kind: 'subblock' },
query,
caseSensitive,
searchTextFormat: subBlockConfig?.searchTextFormat,
editable: leafEditable,
protectedByLock,
isSnapshotView,
@@ -1,3 +1,4 @@
import { foldSearchWhitespace } from '@sim/utils/string'
import {
getWorkflowSearchSubBlockResourceKind,
parseWorkflowSearchSubBlockResources,
@@ -140,18 +141,6 @@ export function parseStructuredResourceReferences(
return parseWorkflowSearchSubBlockResources(value, subBlockConfig, selectorContext)
}
/**
* Maps every Unicode whitespace character to a plain space, one-to-one.
* Agent-authored block names and values routinely carry non-breaking or
* narrow spaces that render identically to " " but never equal a typed
* space, silently hiding matches. The replacement is length-preserving
* (every `\s` character is a single UTF-16 unit), so indexes into the
* folded string remain valid ranges into the original.
*/
export function foldSearchWhitespace(value: string): string {
return value.replace(/\s/g, ' ')
}
export function matchesSearchText(
candidate: string,
query: string | undefined,
@@ -1,4 +1,4 @@
import { foldSearchWhitespace } from '@/lib/workflows/search-replace/resources/references'
import { foldSearchWhitespace } from '@sim/utils/string'
import type {
WorkflowSearchMatch,
WorkflowSearchMatchKind,
@@ -0,0 +1,59 @@
/**
* @vitest-environment jsdom
*
* Deselecting a block must not end a workflow search.
*
* `clearCurrentBlock` used to clear `activeSearchTarget` too, which made a Note
* match unrenderable: the editor answers a Note by deselecting it, destroying
* the target the Note card was about to paint. The search panel owns that
* target's lifetime — it re-asserts it on every match change and clears it on
* close — so nothing else may reach in and drop it.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import type { ActiveSearchTarget } from '@/stores/panel/editor/store'
import { usePanelEditorSearchStore, usePanelEditorStore } from '@/stores/panel/editor/store'
const NOTE_SEARCH_TARGET: ActiveSearchTarget = {
matchId: 'text:note-1:content:0',
blockId: 'note-1',
subBlockId: 'content',
canonicalSubBlockId: 'content',
valuePath: [],
kind: 'text',
targetKind: 'subblock',
subBlockType: 'long-input',
rawValue: 'SB_ACTION_ROUTER_SECRET',
searchText: 'uses SB_ACTION_ROUTER_SECRET here',
query: 'SB_ACTION_ROUTER_SECRET',
range: { start: 5, end: 28 },
}
beforeEach(() => {
usePanelEditorSearchStore.setState({ activeSearchTarget: null })
usePanelEditorStore.setState({ currentBlockId: null })
})
describe('clearCurrentBlock', () => {
it('clears the selected block', () => {
usePanelEditorStore.getState().setCurrentBlockId('block-1')
usePanelEditorStore.getState().clearCurrentBlock()
expect(usePanelEditorStore.getState().currentBlockId).toBeNull()
})
it('leaves the active search target in place', () => {
usePanelEditorSearchStore.getState().setActiveSearchTarget(NOTE_SEARCH_TARGET)
usePanelEditorStore.getState().setCurrentBlockId('note-1')
usePanelEditorStore.getState().clearCurrentBlock()
expect(usePanelEditorSearchStore.getState().activeSearchTarget).toEqual(NOTE_SEARCH_TARGET)
})
})
describe('setActiveSearchTarget', () => {
it('clears the target when the search panel asks it to', () => {
usePanelEditorSearchStore.getState().setActiveSearchTarget(NOTE_SEARCH_TARGET)
usePanelEditorSearchStore.getState().setActiveSearchTarget(null)
expect(usePanelEditorSearchStore.getState().activeSearchTarget).toBeNull()
})
})
+19 -3
View File
@@ -41,7 +41,15 @@ interface PanelEditorState {
currentBlockId: string | null
/** Sets the current selected block identifier (use null to clear) */
setCurrentBlockId: (blockId: string | null) => void
/** Clears the current selection */
/**
* Clears the current selection.
*
* Leaves {@link PanelEditorSearchState.activeSearchTarget} alone: the search
* panel owns that target's lifetime, and deselecting a block is not the end
* of a search. Clearing it here made a Note match impossible to render — the
* editor answers a Note by deselecting it, which destroyed the very target
* the Note card was about to paint.
*/
clearCurrentBlock: () => void
/** Height of the connections section in pixels */
connectionsHeight: number
@@ -56,7 +64,16 @@ interface PanelEditorState {
}
interface PanelEditorSearchState {
/** Ephemeral workflow search target used for scrolling/highlighting editor fields */
/**
* Ephemeral workflow search target used for scrolling/highlighting editor fields.
*
* Re-published with a fresh object identity on most of the search panel's own
* renders — its match-hydration hooks hand back new arrays, so the effect that
* publishes this re-runs constantly with an equal target. Subscribe to the
* fields you need as primitives (or under `useShallow`), never to the object.
* Any component that both holds the object and can re-render the search panel
* closes an unbounded update loop the moment a search opens.
*/
activeSearchTarget: ActiveSearchTarget | null
/** Sets an active search target to highlight in the editor */
setActiveSearchTarget: (target: ActiveSearchTarget | null) => void
@@ -95,7 +112,6 @@ export const usePanelEditorStore = create<PanelEditorState>()(
},
clearCurrentBlock: () => {
set({ currentBlockId: null })
usePanelEditorSearchStore.getState().setActiveSearchTarget(null)
},
setConnectionsHeight: (height) => {
const clampedHeight = Math.max(