Skip to content

Commit 2e111f6

Browse files
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>
1 parent a27f376 commit 2e111f6

19 files changed

Lines changed: 1565 additions & 34 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block.tsx

Lines changed: 146 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import {
33
BLOCK_DIMENSIONS,
4+
countNoteSearchOccurrencesBefore,
45
DEFAULT_NOTE_COLOR,
56
estimateNoteBlockHeight,
7+
forEachNoteSourceOccurrence,
68
getNoteStringValue,
79
isNoteColor,
810
NoteBlockView,
911
type NoteColor,
1012
type NoteContentEditorProps,
13+
type NoteSearchHighlight,
14+
type NoteSearchRange,
1115
} from '@sim/workflow-renderer'
1216
import dynamic from 'next/dynamic'
1317
import { type NodeProps, useReactFlow } from 'reactflow'
18+
import { useShallow } from 'zustand/react/shallow'
1419
import { appendNoteImageMarkdown } from '@/lib/workflows/notes/add-image'
1520
import {
1621
NOTE_ADD_IMAGE_EVENT,
@@ -26,7 +31,7 @@ import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]
2631
import { isBlockProtected } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
2732
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
2833
import { useIsCurrentWorkflowExecuting } from '@/stores/execution'
29-
import { usePanelEditorStore } from '@/stores/panel'
34+
import { usePanelEditorSearchStore, usePanelEditorStore } from '@/stores/panel'
3035
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
3136
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
3237

@@ -48,6 +53,9 @@ const NoteMarkdownEditor = dynamic(
4853

4954
const NOTE_EXPAND_FOCUS_DURATION_MS = 300
5055

56+
/** The markdown body's sub-block id, as declared by the Note block config. */
57+
const NOTE_CONTENT_SUBBLOCK_ID = 'content'
58+
5159
function renderNoteContentEditor(props: NoteContentEditorProps) {
5260
return <NoteMarkdownEditor {...props} />
5361
}
@@ -106,7 +114,92 @@ export const NoteBlock = memo(function NoteBlock({
106114
useCallback((state) => isBlockProtected(id, state.blocks), [id])
107115
)
108116
const clearCurrentBlock = usePanelEditorStore((state) => state.clearCurrentBlock)
117+
/* Flattened to primitives under a shallow compare, never held as the target
118+
object: the search panel re-publishes an equal target on most of its own
119+
renders, and a card that subscribes to the object rebuilds its highlight on
120+
every one of them. */
121+
const searchTarget = usePanelEditorSearchStore(
122+
useShallow((state) => {
123+
const target = state.activeSearchTarget
124+
return {
125+
blockId: target?.blockId ?? null,
126+
subBlockId: target?.subBlockId ?? null,
127+
targetKind: target?.targetKind ?? null,
128+
query: target?.query ?? null,
129+
rawValue: target?.rawValue ?? null,
130+
rangeStart: target?.range?.start ?? null,
131+
rangeEnd: target?.range?.end ?? null,
132+
}
133+
})
134+
)
109135
const canEditNote = canEditWorkflow && !data.isPreview && !isProtected
136+
137+
/** Whether the active search match belongs to this card at all. */
138+
const isSearchTargetBlock =
139+
!data.isPreview &&
140+
!data.isEmbedded &&
141+
searchTarget.blockId === id &&
142+
Boolean(searchTarget.query)
143+
144+
/**
145+
* The workflow search match this card should paint in its body.
146+
*
147+
* The panel editor renders nothing for a note, so the card is the only
148+
* surface that can answer a search — without this a note match counts towards
149+
* "1 of 6" and then highlights nowhere.
150+
*
151+
* The stored range is re-checked against the live content before it is
152+
* trusted: the index runs over a snapshot, and a collaborator's edit between
153+
* indexing and painting would shift the ordinal onto the wrong words. When it
154+
* no longer holds — or the match never carried one — this falls back to the
155+
* first rendered occurrence, which is the same fallback the editor panel
156+
* makes for a label whose stored range does not fit.
157+
*/
158+
const searchHighlight = useMemo<NoteSearchHighlight | null>(() => {
159+
if (!isSearchTargetBlock) return null
160+
if (searchTarget.targetKind !== 'subblock') return null
161+
if (searchTarget.subBlockId !== NOTE_CONTENT_SUBBLOCK_ID) return null
162+
163+
const { query, rawValue, rangeStart, rangeEnd } = searchTarget
164+
if (!query) return null
165+
166+
const rangeHolds =
167+
rangeStart !== null && rangeEnd !== null && content.slice(rangeStart, rangeEnd) === rawValue
168+
if (rangeHolds) {
169+
return {
170+
query,
171+
occurrenceIndex: countNoteSearchOccurrencesBefore(content, query, rangeStart),
172+
}
173+
}
174+
175+
/* Asked through the same scan that will do the marking, rather than a local
176+
`includes`: a bare comparison skips the whitespace fold, so a query the
177+
indexer matched across a newline or a non-breaking space would read as
178+
absent here and the card would paint nothing while the panel counted it. */
179+
let occurs = false
180+
forEachNoteSourceOccurrence(content, query, () => {
181+
occurs = true
182+
})
183+
return occurs ? { query, occurrenceIndex: 0 } : null
184+
}, [content, isSearchTargetBlock, searchTarget])
185+
186+
/**
187+
* The match to paint in the title, for a search that hit the note's name.
188+
*
189+
* A name match carries an exact range over `block.name`, so unlike the body
190+
* there is no occurrence to reconstruct — it is used directly, once it still
191+
* describes the live name.
192+
*/
193+
const nameSearchRange = useMemo<NoteSearchRange | null>(() => {
194+
if (!isSearchTargetBlock) return null
195+
if (searchTarget.targetKind !== 'block-name') return null
196+
197+
const { rawValue, rangeStart, rangeEnd } = searchTarget
198+
if (rangeStart === null || rangeEnd === null) return null
199+
if ((name ?? '').slice(rangeStart, rangeEnd) !== rawValue) return null
200+
201+
return { start: rangeStart, end: rangeEnd }
202+
}, [isSearchTargetBlock, name, searchTarget])
110203
const uploadNoteImage = useNoteImageUpload()
111204
const imageInputRef = useRef<HTMLInputElement>(null)
112205
const [blockHeight, setBlockHeight] = useState(() => estimateNoteBlockHeight(content))
@@ -132,14 +225,56 @@ export const NoteBlock = memo(function NoteBlock({
132225
[]
133226
)
134227

228+
const isExpandedRef = useRef(isExpanded)
229+
useEffect(() => {
230+
isExpandedRef.current = isExpanded
231+
}, [isExpanded])
232+
233+
/**
234+
* Opens the card while it holds the current search match, and closes it again
235+
* when the match moves on.
236+
*
237+
* A compact note shows a few lines of what is often a long document, so a
238+
* match found deep inside it lands in a body the user cannot read. Expanding
239+
* is the same gesture a click makes, and it gives the mark somewhere to be.
240+
*
241+
* Two things this deliberately does not do. It does not call
242+
* {@link handleExpandedChange}, whose own `setCenter` would race the camera
243+
* the canvas already moved for this match. And it only closes a card it
244+
* opened — a note the user expanded by hand, or collapsed by hand while the
245+
* match still points here, is left exactly as they left it.
246+
*/
247+
const searchExpandedRef = useRef(false)
248+
const hasSearchMatch = searchHighlight !== null || nameSearchRange !== null
249+
useEffect(() => {
250+
/* Losing edit rights already force-collapses the card during render. Drop the latch with it,
251+
or regaining them would hit the early return below and leave a deep match clipped in the
252+
compact body until the active match moved. */
253+
if (!canEditNote) {
254+
searchExpandedRef.current = false
255+
return
256+
}
257+
258+
if (hasSearchMatch) {
259+
if (searchExpandedRef.current || isExpandedRef.current) return
260+
searchExpandedRef.current = true
261+
setIsExpanded(true)
262+
return
263+
}
264+
265+
if (!searchExpandedRef.current) return
266+
searchExpandedRef.current = false
267+
setIsExpanded(false)
268+
}, [canEditNote, hasSearchMatch])
269+
135270
const handleNameChange = (nextName: string) => {
136271
if (!canEditNote) return false
137272
return collaborativeUpdateBlockName(id, nextName).success
138273
}
139274

140275
const handleContentChange = (nextContent: string) => {
141276
if (!canEditNote) return
142-
collaborativeSetSubblockValue(id, 'content', nextContent)
277+
collaborativeSetSubblockValue(id, NOTE_CONTENT_SUBBLOCK_ID, nextContent)
143278
}
144279

145280
/**
@@ -175,9 +310,14 @@ export const NoteBlock = memo(function NoteBlock({
175310
if (!image) continue
176311
/* Re-read per image: each append has to build on the previous one, and on
177312
anything a collaborator wrote while the upload was in flight. */
178-
const current = getNoteStringValue(useSubBlockStore.getState().getValue(id, 'content')) ?? ''
313+
const current =
314+
getNoteStringValue(useSubBlockStore.getState().getValue(id, NOTE_CONTENT_SUBBLOCK_ID)) ?? ''
179315
setExternalContentWrites((count) => count + 1)
180-
collaborativeSetSubblockValue(id, 'content', appendNoteImageMarkdown(current, image))
316+
collaborativeSetSubblockValue(
317+
id,
318+
NOTE_CONTENT_SUBBLOCK_ID,
319+
appendNoteImageMarkdown(current, image)
320+
)
181321
}
182322
}
183323

@@ -292,6 +432,8 @@ export const NoteBlock = memo(function NoteBlock({
292432
onExpandedChange={handleExpandedChange}
293433
onImageFilesDrop={(files) => void insertImages(files)}
294434
renderContentEditor={renderNoteContentEditor}
435+
searchHighlight={searchHighlight}
436+
nameSearchRange={nameSearchRange}
295437
actionBar={
296438
<ActionBar
297439
blockId={id}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ import {
145145
} from '@/stores/execution'
146146
import { useSearchModalStore } from '@/stores/modals/search/store'
147147
import type { PendingConnect } from '@/stores/modals/search/types'
148-
import { usePanelEditorStore, usePanelStore } from '@/stores/panel'
148+
import { usePanelEditorSearchStore, usePanelEditorStore, usePanelStore } from '@/stores/panel'
149149
import { useUndoRedoStore } from '@/stores/undo-redo'
150150
import { useVariablesModalStore } from '@/stores/variables/modal'
151151
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
@@ -4608,6 +4608,73 @@ const WorkflowContent = React.memo(
46084608
return () => window.removeEventListener('keydown', handleArrowNavigation, true)
46094609
}, [embedded, getNodes, blocks, focusBlockInView])
46104610

4611+
/**
4612+
* Brings a Note holding the current search match onto the canvas.
4613+
*
4614+
* Every other block answers a search through the editor panel, which scrolls
4615+
* the matching field into view for free. A Note renders nothing there — the
4616+
* card itself is the surface — so the camera has to do that job here, and
4617+
* the card has to be selected before it will hold a scroll position deep in
4618+
* its own body rather than snapping back to the top.
4619+
*
4620+
* Keyed on the match rather than the block, so cycling between two matches
4621+
* in one Note re-asserts a camera that is already where it needs to be
4622+
* (visually inert) instead of stranding the second match off-screen after
4623+
* the user has panned away. Matches on every other kind of block are marked
4624+
* handled and otherwise left alone — without that, walking away to a block
4625+
* match and back to a Note one would read as the same match twice and skip
4626+
* the camera the second time.
4627+
*
4628+
* Also covers a match on the Note's *name*, which the card cannot underline
4629+
* but which at least lands the user on the right card.
4630+
*
4631+
* Subscribed as two ids and NEVER as the target object. The search panel
4632+
* renders inside this component and re-publishes an equal target on most of
4633+
* its own renders (its hydration hooks hand back fresh arrays), so holding
4634+
* the object here re-renders the panel, whose effect re-publishes, which
4635+
* re-renders it again — an unbounded update loop the moment a search opens.
4636+
* Two string selectors are compared by value, so a re-publish of the same
4637+
* match is inert.
4638+
*/
4639+
const searchMatchId = usePanelEditorSearchStore(
4640+
(state) => state.activeSearchTarget?.matchId ?? null
4641+
)
4642+
const searchMatchBlockId = usePanelEditorSearchStore(
4643+
(state) => state.activeSearchTarget?.blockId ?? null
4644+
)
4645+
const focusedSearchMatchIdRef = useRef<string | null>(null)
4646+
useEffect(() => {
4647+
if (embedded) return
4648+
if (!searchMatchId || !searchMatchBlockId) {
4649+
focusedSearchMatchIdRef.current = null
4650+
return
4651+
}
4652+
if (searchMatchId === focusedSearchMatchIdRef.current) return
4653+
4654+
if (blocks[searchMatchBlockId]?.type !== 'note') {
4655+
focusedSearchMatchIdRef.current = searchMatchId
4656+
return
4657+
}
4658+
4659+
/* Read from `displayNodes` rather than `getNodes()` so a match that
4660+
arrives before its node has mounted is retried on the commit that
4661+
mounts it, instead of being dropped. */
4662+
const node = displayNodes.find((candidate) => candidate.id === searchMatchBlockId)
4663+
if (!node) return
4664+
4665+
focusedSearchMatchIdRef.current = searchMatchId
4666+
setDisplayNodes((currentNodes) =>
4667+
resolveSelectionConflicts(
4668+
currentNodes.map((currentNode) => ({
4669+
...currentNode,
4670+
selected: currentNode.id === node.id,
4671+
})),
4672+
blocks
4673+
)
4674+
)
4675+
focusBlockInView(node)
4676+
}, [blocks, displayNodes, embedded, focusBlockInView, searchMatchBlockId, searchMatchId])
4677+
46114678
/** Handles edge selection with container context tracking and Shift-click multi-selection. */
46124679
const onEdgeClick = useCallback(
46134680
(event: React.MouseEvent, edge: any) => {

apps/sim/blocks/blocks/note.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const NoteBlock: BlockConfig = {
1515
{
1616
id: 'content',
1717
type: 'long-input',
18+
searchTextFormat: 'markdown',
1819
rows: 8,
1920
placeholder: 'Add context or instructions for collaborators...',
2021
description: 'Write your note using Markdown. YouTube links will display as embedded videos.',

apps/sim/blocks/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,19 @@ export interface SubBlockConfig {
269269
type: SubBlockType
270270
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
271271
canonicalParamId?: string
272+
/**
273+
* Declares that the stored value is markdown, so workflow search matches it
274+
* against the text it RENDERS as rather than its source.
275+
*
276+
* The rich-text editor backslash-escapes every markdown-significant character
277+
* in prose, so a Note body the reader sees as `SB_ACTION` is stored as
278+
* `SB\_ACTION` and would otherwise be unfindable by what is on screen. Ranges
279+
* stay in source coordinates, so replace still rewrites the escaped span.
280+
*
281+
* Omit for every ordinary field: a code or plain-text value is searched as
282+
* stored, where a backslash is the author's own character.
283+
*/
284+
searchTextFormat?: 'markdown'
272285
/** Controls parameter visibility in agent/tool-input context */
273286
paramVisibility?: 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden'
274287
/**

0 commit comments

Comments
 (0)