';
+ const prompt = thread.querySelectorAll(
+ '[data-chat-prompt-content]',
+ )[1];
+ expect(getMatchRoot?.(thread)).toBe(prompt);
+ thread.insertAdjacentHTML(
+ 'beforeend',
+ 'recent test test',
+ );
+ expect(getMatchRoot?.(thread)).toBeNull();
+ thread.innerHTML = 'earlier test';
+ expect(getMatchRoot?.(thread)).toBeNull();
+ });
+
+ it('groups interleaved matches by field and labels each field only once', () => {
+ const rows: SearchResultRow[] = [
+ results[0],
+ {
+ ...results[0],
+ key: 'first:summary:0',
+ match: { ...results[0].match, field: 'summary' },
+ },
+ {
+ ...results[0],
+ key: 'first:content:1',
+ match: { ...results[0].match, occurrenceIndex: 1 },
+ },
+ ];
+ act(() => useSearchStore.setState({ results: rows }));
+ expect(
+ Array.from(
+ container?.querySelectorAll('[data-search-field]') ?? [],
+ (element) => element.getAttribute('data-search-field'),
+ ),
+ ).toEqual(['content', 'content', 'summary']);
+ expect(
+ Array.from(
+ container?.querySelectorAll('.uppercase') ?? [],
+ (element) => element.textContent,
+ ),
+ ).toEqual(['Content', 'Summary']);
+ act(() => getRow(2).focus());
+ press(getRow(2), 'Enter');
+ expect(usePreviewSearchStore.getState()).toMatchObject({
+ nodeId: 'first',
+ query: 'test',
+ isOpen: true,
+ focusInput: false,
+ navigationRequest: { occurrenceIndex: 1 },
+ });
+ });
+
+ it('hands the clicked content match to preview search without stealing result focus', () => {
+ act(() => getRow(3).focus());
+ act(() => getRow(3).click());
+ expect(openPreviewNode).toHaveBeenCalledExactlyOnceWith('second', {
+ transient: true,
+ focusEditor: false,
+ });
+ expect(usePreviewSearchStore.getState()).toMatchObject({
+ nodeId: 'second',
+ query: 'test',
+ isOpen: true,
+ focusInput: false,
+ navigationRequest: { occurrenceIndex: 0 },
+ });
+ expect(document.activeElement).toBe(getRow(3));
+ });
+
+ it('cancels a pending conversation jump when activating another result or closing search', () => {
+ const cancel = vi.fn();
+ scheduleScrollToMatch.mockReturnValue(cancel);
+ canvasState.nodes.push({ id: 'first', data: { threadId: 'thread' } });
+ act(() =>
+ useSearchStore.setState({
+ results: [
+ {
+ ...results[0],
+ match: { ...results[0].match, field: 'conversation' },
+ },
+ results[1],
+ ],
+ }),
+ );
+ act(() => getRow(1).click());
+ act(() => getRow(3).click());
+ expect(cancel).toHaveBeenCalledTimes(1);
+ act(() => getRow(1).click());
+ act(() => useSearchStore.getState().close());
+ expect(cancel).toHaveBeenCalledTimes(2);
+ scheduleScrollToMatch.mockReset();
+ });
+});
+
+describe('Canvas search keyboard focus', () => {
+ it('activates the second group when its header receives Tab focus', () => {
+ const second = getRow(2);
+ act(() => second.focus());
+ press(second, 'Enter');
+ expect(useSearchStore.getState().collapsedNodeIds.has('second')).toBe(true);
+ expect(useSearchStore.getState().collapsedNodeIds.has('first')).toBe(false);
+ });
+
+ it('moves actual result focus with arrow navigation before Enter activation', () => {
+ act(() => getRow(0).focus());
+ press(getRow(0), 'ArrowDown');
+ expect(document.activeElement).toBe(getRow(1));
+ press(getRow(1), 'Enter');
+ expect(openPreviewNode).toHaveBeenCalledExactlyOnceWith('first', {
+ transient: true,
+ focusEditor: false,
+ });
+ expect(useSearchStore.getState().collapsedNodeIds.size).toBe(0);
+ press(getRow(1), 'ArrowDown');
+ expect(document.activeElement).toBe(getRow(2));
+ press(getRow(2), 'Enter');
+ expect(useSearchStore.getState().collapsedNodeIds.has('second')).toBe(true);
+ });
+
+ it('keeps virtual result navigation in the search input without moving DOM focus', () => {
+ if (!searchInput) throw new Error('Missing search input');
+ act(() => searchInput?.focus());
+ press(searchInput, 'ArrowDown');
+ expect(document.activeElement).toBe(searchInput);
+ press(searchInput, 'Enter');
+ expect(openPreviewNode).toHaveBeenCalledExactlyOnceWith('first', {
+ transient: true,
+ focusEditor: false,
+ });
+ });
+
+ it('continues arrow navigation from a clicked match', () => {
+ act(() => getRow(3).click());
+ expect(openPreviewNode).toHaveBeenCalledWith('second', {
+ transient: true,
+ focusEditor: false,
+ });
+ if (!searchInput) throw new Error('Missing search input');
+ act(() => searchInput?.focus());
+ press(searchInput, 'ArrowUp');
+ press(searchInput, 'Enter');
+ expect(useSearchStore.getState().collapsedNodeIds.has('second')).toBe(true);
+ expect(useSearchStore.getState().collapsedNodeIds.has('first')).toBe(false);
+ });
+
+ it('does not consume Enter from unrelated controls', () => {
+ const other = document.createElement('button');
+ document.body.appendChild(other);
+ try {
+ act(() => other.focus());
+ expect(press(other, 'Enter').defaultPrevented).toBe(false);
+ expect(useSearchStore.getState().collapsedNodeIds.size).toBe(0);
+ expect(selectNodes).not.toHaveBeenCalled();
+ } finally {
+ other.remove();
+ }
+ });
+});
diff --git a/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.tsx b/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.tsx
index 172d5e580..7890cf573 100644
--- a/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.tsx
+++ b/apps/web/src/components/Panels/CanvasLayerPanel/CanvasSearchResults.tsx
@@ -8,15 +8,13 @@
* toggle).
*
* Behaviour parity with the previous floating overlay:
- * - Results stream in already grouped by node (per emission
- * order). We bucket adjacent rows by `nodeId`, render a header
- * row per group with the canonical node icon (Spline for edges),
- * and indent match rows beneath their header.
- * - ↑ / ↓ navigates the flat (header + visible match) list, live-
- * follows on the canvas (minimal reveal + a preview open when the
- * target has a real preview).
+ * - Results are grouped by node and then field in first-seen order,
+ * with one heading per field and the canonical node icon per node.
+ * - ↑ / ↓ navigates the flat (header + visible match) list and
+ * follows on the canvas without opening Preview Workspace tabs.
* - Enter on a header toggles collapse; Enter on a match opens the
- * owning node without injecting search state into its preview.
+ * owning node and hands content matches to preview-local search
+ * or reveals the matching Question conversation occurrence.
* - ←/→ collapse / expand the current group.
* - Highlight ranges are painted on the canvas DOM only; expanded
* previews own an independent search layer.
@@ -34,8 +32,11 @@
import { ChevronDown, ChevronRight, TriangleAlert } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
+import { usePreviewSearchStore } from '@/store/previewSearchStore';
+
import { shouldCanvasSearchOwnKeyboard } from './canvasSearchKeyboard';
import { revealNodesOnCanvas } from './focusNodesOnCanvas';
import { CanvasSearchNodeIcon } from './LayerNodeIcon';
@@ -62,10 +63,13 @@ import { cn } from '../../Common/cn';
import { toast } from '../../Common/Toast';
import { hasNodePreview } from '../../Nodes/previews';
-const ROW_HEIGHT = 40;
+const ROW_HEIGHT = 28;
export const CanvasSearchResults = (): React.JSX.Element => {
+ const { t } = useTranslation();
const query = useSearchStore((s) => s.query);
+ const normalizedQuery = query.trim();
+ const nodeTypes = useSearchStore((s) => s.nodeTypes);
const results = useSearchStore((s) => s.results);
const isStreaming = useSearchStore((s) => s.isStreaming);
const contentPhase = useSearchStore((s) => s.contentPhase);
@@ -87,10 +91,17 @@ export const CanvasSearchResults = (): React.JSX.Element => {
// re-query the chat DOM for the highlight + scroll roots.
const virtuosoRef = useRef(null);
- const [activeIdx, setActiveIdx] = useState(0);
+ const resultsRef = useRef(null);
+ const cancelNavigationRef = useRef<(() => void) | null>(null);
+ const [activeSelection, setActiveSelection] = useState<{
+ key: string;
+ nodeId: string;
+ } | null>(null);
const [canvasRoot, setCanvasRoot] = useState(null);
const [chatThreadEl, setChatThreadEl] = useState(null);
+ useEffect(() => () => cancelNavigationRef.current?.(), [query, scope]);
+
// Resolve the canvas highlight root once on mount and whenever the
// search scope flips back on (e.g. after `close()` + new query).
// The element is owned by React Flow and is stable across renders,
@@ -105,21 +116,6 @@ export const CanvasSearchResults = (): React.JSX.Element => {
);
}, [scope]);
- // Resolve the chat-thread scroll root whenever a question node's
- // conversation is open. Only resolved while its preview tab
- // is set so we never paint search highlights over the unrelated
- // canvas chat; cleared otherwise. Re-runs on `activeIdx` so the
- // element is re-read after the thread swaps to a different node.
- useEffect(() => {
- if (!expandedNodeId) {
- setChatThreadEl(null);
- return;
- }
- setChatThreadEl(
- document.querySelector('[data-chat-thread-root]') ?? null,
- );
- }, [expandedNodeId, activeIdx]);
-
// Paint the same `::highlight(huabu-search)` ranges over node labels
// and Milkdown bodies visible on the canvas and, for conversation
// matches, over the open question-node chat thread. Expanded previews
@@ -129,14 +125,14 @@ export const CanvasSearchResults = (): React.JSX.Element => {
// hydrated from history).
useTextHighlight({
container: [canvasRoot, chatThreadEl],
- query,
+ query: query.trim(),
maxRanges: 800,
});
// Reset selection when results identity changes (new query / scope).
useEffect(() => {
- setActiveIdx(0);
- }, [results]);
+ setActiveSelection(null);
+ }, [normalizedQuery, scope, nodeTypes]);
// ──────────────────────────────────────────────────────────────────
// Per-node grouping (VS Code-style).
@@ -193,24 +189,72 @@ export const CanvasSearchResults = (): React.JSX.Element => {
for (const g of nodeGroups) {
out.push({ kind: 'header', group: g });
if (!collapsedNodeIds.has(g.nodeId)) {
- for (const r of g.rows) out.push({ kind: 'match', group: g, row: r });
+ const fields = new Map<
+ SearchResultRow['match']['field'],
+ SearchResultRow[]
+ >();
+ for (const row of g.rows) {
+ const matches = fields.get(row.match.field);
+ if (matches) matches.push(row);
+ else fields.set(row.match.field, [row]);
+ }
+ for (const matches of fields.values()) {
+ matches.forEach((row, index) =>
+ out.push({
+ kind: 'match',
+ group: g,
+ row,
+ firstInField: index === 0,
+ }),
+ );
+ }
}
}
return out;
}, [nodeGroups, collapsedNodeIds]);
- // Clamp activeIdx whenever `visibleRows` shrinks (user collapsed a
- // group, streaming reset the list, etc.) so it can never dangle past
- // the end and silently disable Enter / live-follow.
+ const selectedIndex = visibleRows.findIndex(
+ (row) => visibleRowKey(row) === activeSelection?.key,
+ );
+ const activeIdx = Math.max(
+ 0,
+ selectedIndex >= 0
+ ? selectedIndex
+ : visibleRows.findIndex(
+ (row) =>
+ row.kind === 'header' &&
+ row.group.nodeId === activeSelection?.nodeId,
+ ),
+ );
+ const setActiveIdx = useCallback(
+ (index: number) => {
+ const row = visibleRows[index];
+ setActiveSelection(
+ row ? { key: visibleRowKey(row), nodeId: row.group.nodeId } : null,
+ );
+ },
+ [visibleRows],
+ );
+
+ // Re-read the addressed thread after its row or preview tab changes.
useEffect(() => {
- if (visibleRows.length === 0) {
- if (activeIdx !== 0) setActiveIdx(0);
+ if (!expandedNodeId) {
+ setChatThreadEl(null);
return;
}
- if (activeIdx >= visibleRows.length) {
- setActiveIdx(visibleRows.length - 1);
- }
- }, [visibleRows, activeIdx]);
+ const threadId = useCanvasStore
+ .getState()
+ .nodes.find((node) => node.id === expandedNodeId)?.data.threadId;
+ setChatThreadEl(
+ Array.from(
+ document.querySelectorAll('[data-chat-thread-root]'),
+ ).find(
+ (element) =>
+ element.dataset.chatThreadId === threadId &&
+ !element.closest('[data-preview-active="false"]'),
+ ) ?? null,
+ );
+ }, [expandedNodeId, activeIdx]);
const focusNodeOnCanvas = useCallback(
(nodeId: string) => {
@@ -266,6 +310,10 @@ export const CanvasSearchResults = (): React.JSX.Element => {
const jumpToResult = useCallback(
(row: SearchResultRow) => {
+ if (!useSearchStore.getState().results.includes(row)) return;
+ const navigationQuery = query.trim();
+ cancelNavigationRef.current?.();
+ cancelNavigationRef.current = null;
const { nodeId, nodeType } = row.match;
// Edge rows share the group focus path: fit on both endpoints,
// never open the (non-existent) edge preview.
@@ -290,13 +338,16 @@ export const CanvasSearchResults = (): React.JSX.Element => {
// Open that thread in the right panel (instead of an expanded
// preview); the dedicated effect below highlights + scrolls to
// the matched message inside it.
- if (row.match.field === 'conversation') {
+ if (
+ row.match.field === 'conversation' ||
+ (nodeType === 'question' && row.match.field === 'content')
+ ) {
if (openConversationForNode(nodeId) && query) {
const node = useCanvasStore
.getState()
.nodes.find((entry) => entry.id === nodeId);
const threadId = node?.data.threadId;
- scheduleScrollToMatch(
+ cancelNavigationRef.current = scheduleScrollToMatch(
() =>
Array.from(
document.querySelectorAll(
@@ -307,14 +358,46 @@ export const CanvasSearchResults = (): React.JSX.Element => {
element.dataset.chatThreadId === threadId &&
!element.closest('[data-preview-active="false"]'),
) ?? null,
- query,
+ navigationQuery,
row.match.occurrenceIndex,
{
+ source:
+ row.match.field === 'conversation'
+ ? row.match.source
+ : undefined,
+ requireSource: row.match.field === 'conversation',
+ getMatchRoot:
+ row.match.field === 'content'
+ ? (root) => {
+ const content = useCanvasStore
+ .getState()
+ .nodes.find((entry) => entry.id === nodeId)
+ ?.data.content;
+ if (typeof content !== 'string' || !content.trim())
+ return null;
+ const prompts = Array.from(
+ root.querySelectorAll(
+ '[data-chat-prompt-content]',
+ ),
+ ).filter(
+ (prompt) =>
+ prompt.textContent?.trim() === content.trim(),
+ );
+ return prompts.length === 1 ? (prompts[0] ?? null) : null;
+ }
+ : undefined,
+ onMatch: (range) => {
+ setChatThreadEl(
+ range.startContainer.parentElement?.closest(
+ '[data-chat-thread-root]',
+ ) ?? null,
+ );
+ },
onTimeout: () =>
- toast(
- 'Match not visible in the conversation — scroll manually to find it.',
- { tone: 'info', duration: 4000 },
- ),
+ toast(t('search.conversationMatchNotVisible'), {
+ tone: 'info',
+ duration: 4000,
+ }),
},
);
}
@@ -325,11 +408,23 @@ export const CanvasSearchResults = (): React.JSX.Element => {
// without a `NodePreviews` entry leave the user's current preview alone.
if (hasNodePreview(nodeType)) {
// Browsing results reuses the group's inspection slot (§9.2).
- openPreviewNode(nodeId, { transient: true });
+ openPreviewNode(nodeId, { transient: true, focusEditor: false });
+ if (row.match.field === 'content') {
+ usePreviewSearchStore
+ .getState()
+ .reveal(
+ nodeId,
+ navigationQuery,
+ row.match.occurrenceIndex,
+ row.match.source,
+ );
+ }
+ } else if (nodeType === 'question') {
+ openConversationForNode(nodeId);
}
focusNodeOnCanvas(nodeId);
},
- [focusNodeOnCanvas, focusGroupOnCanvas, openConversationForNode, query],
+ [focusNodeOnCanvas, focusGroupOnCanvas, openConversationForNode, query, t],
);
// Live-follow only on the Canvas. Browsing with ↑ / ↓ must not open,
@@ -369,13 +464,13 @@ export const CanvasSearchResults = (): React.JSX.Element => {
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
- setActiveIdx((i) => Math.min(visibleRows.length - 1, i + 1));
+ setActiveIdx(Math.min(visibleRows.length - 1, activeIdx + 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
- setActiveIdx((i) => Math.max(0, i - 1));
+ setActiveIdx(Math.max(0, activeIdx - 1));
return;
}
if (e.key === 'ArrowLeft') {
@@ -407,7 +502,7 @@ export const CanvasSearchResults = (): React.JSX.Element => {
if (collapsedNodeIds.has(v.group.nodeId)) {
toggleNodeCollapse(v.group.nodeId);
} else if (v.group.rows.length > 0) {
- setActiveIdx((i) => Math.min(visibleRows.length - 1, i + 1));
+ setActiveIdx(Math.min(visibleRows.length - 1, activeIdx + 1));
}
return;
}
@@ -420,17 +515,43 @@ export const CanvasSearchResults = (): React.JSX.Element => {
collapsedNodeIds,
toggleNodeCollapse,
jumpToResult,
+ setActiveIdx,
]);
// Keep the active row scrolled into view via Virtuoso's imperative
// API.
useEffect(() => {
if (visibleRows.length === 0) return;
+ const root = resultsRef.current;
+ const focusedResult = root?.contains(document.activeElement)
+ ? document.activeElement
+ : null;
+ let cancelled = false;
virtuosoRef.current?.scrollIntoView({
index: activeIdx,
behavior: 'auto',
align: 'center',
+ done: () => {
+ if (cancelled) return;
+ const activeElement = document.activeElement;
+ if (
+ root &&
+ (root.contains(activeElement) ||
+ (focusedResult &&
+ !focusedResult.isConnected &&
+ activeElement === document.body))
+ ) {
+ root
+ .querySelector(
+ `[data-search-row-index="${activeIdx}"]`,
+ )
+ ?.focus({ preventScroll: true });
+ }
+ },
});
+ return () => {
+ cancelled = true;
+ };
}, [activeIdx, visibleRows.length]);
const showEmpty =
@@ -438,6 +559,7 @@ export const CanvasSearchResults = (): React.JSX.Element => {
return (
@@ -451,10 +573,7 @@ export const CanvasSearchResults = (): React.JSX.Element => {
className="text-warning mt-0.5 shrink-0"
aria-hidden
/>
-
- The result set only contains a subset of all matches. Be more
- specific in your search to narrow down the results.
-
+ {t('search.resultsTruncated')}
';
+ const unregister = Array.from(scope.children, (element) =>
+ registerSearchSource(element as HTMLElement, 'needle', 'text'),
+ );
+ const onMatch = vi.fn();
+ const onTimeout = vi.fn();
+ const cancel = scheduleScrollToMatch(() => scope, 'needle', 1, {
+ source: { hash: await hashSearchSource('needle'), offset: 0 },
+ onMatch,
+ onTimeout,
+ });
+ await vi.waitFor(() => expect(onTimeout).toHaveBeenCalledTimes(1));
+ expect(onMatch).not.toHaveBeenCalled();
+ cancel();
+ unregister.forEach((cleanup) => cleanup());
+ });
+
+ it('maps a PDF phrase across separate text-layer spans without requiring DOM spaces', async () => {
+ const { findProjectedRange } = await import('./searchDom');
+ scope.innerHTML = 'needlephrase';
+ expect(findProjectedRange(scope, 'needle phrase', 0, 13)?.toString()).toBe(
+ 'needlephrase',
+ );
+ expect(findProjectedRange(scope, 'different phrase', 0, 9)).toBeNull();
+ });
+});
+
+it('makes the matched range visible inside a paragraph taller than its scroll panel', () => {
+ scope.style.overflowY = 'auto';
+ const paragraph = document.createElement('p');
+ paragraph.textContent = 'filler '.repeat(1000) + 'needle';
+ scope.append(paragraph);
+ const range = findNthRange(paragraph, 'needle', 0);
+ if (!range) throw new Error('Missing fixture occurrence');
+ vi.spyOn(scope, 'getBoundingClientRect').mockReturnValue(
+ new DOMRect(0, 0, 300, 200),
+ );
+ vi.spyOn(paragraph, 'getBoundingClientRect').mockImplementation(
+ () => new DOMRect(0, -scope.scrollTop, 300, 2000),
+ );
+ vi.spyOn(range, 'getBoundingClientRect').mockImplementation(
+ () => new DOMRect(0, 1960 - scope.scrollTop, 80, 20),
+ );
+ vi.spyOn(paragraph, 'scrollIntoView').mockImplementation(() => {
+ scope.scrollTop = 900;
+ });
+ scrollRangeIntoView(range);
+ const after = range.getBoundingClientRect();
+ expect(after.top).toBeGreaterThanOrEqual(0);
+ expect(after.bottom).toBeLessThanOrEqual(200);
+});
diff --git a/apps/web/src/hooks/useSearchSource.ts b/apps/web/src/hooks/useSearchSource.ts
new file mode 100644
index 000000000..2dfdcebe3
--- /dev/null
+++ b/apps/web/src/hooks/useSearchSource.ts
@@ -0,0 +1,142 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import { useEffect } from 'react';
+
+import { literalTextMatches } from '@huabu/shared';
+import { markdownSearchTarget } from '@huabu/shared/canvas-engine';
+
+import type { CanvasSearchSource } from '@huabu/shared';
+import type { RefObject } from 'react';
+
+interface SearchSource {
+ text: string;
+ format: 'markdown' | 'text';
+ resolveRange?: (text: string, start: number, end: number) => Range | null;
+}
+
+const sources = new WeakMap();
+
+export async function hashSearchSource(text: string): Promise {
+ const bytes = new TextEncoder().encode(text);
+ const digest = globalThis.crypto?.subtle
+ ? new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes))
+ : (await import('@noble/hashes/sha256')).sha256(bytes);
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join(
+ '',
+ );
+}
+
+export function registerSearchSource(
+ root: HTMLElement,
+ text: string,
+ format: SearchSource['format'],
+ resolveRange?: SearchSource['resolveRange'],
+): () => void {
+ const source = { text, format, resolveRange };
+ sources.set(root, source);
+ root.setAttribute('data-search-source', '');
+ void Promise.resolve()
+ .then(() => hashSearchSource(text))
+ .then((hash) => {
+ if (sources.get(root) !== source) return;
+ root.setAttribute('data-search-source', hash);
+ })
+ .catch((error: unknown) => {
+ console.error('[search] Cannot identify rendered source', error);
+ });
+ return () => {
+ if (sources.get(root) !== source) return;
+ sources.delete(root);
+ root.removeAttribute('data-search-source');
+ };
+}
+
+export function useSearchSource(
+ root: RefObject,
+ text: string,
+ format: SearchSource['format'],
+ resolver?: RefObject<{
+ getSearchRange: NonNullable;
+ } | null>,
+): void {
+ useEffect(() => {
+ if (!root.current) return;
+ return registerSearchSource(
+ root.current,
+ text,
+ format,
+ resolver
+ ? (...args) => resolver.current?.getSearchRange(...args) ?? null
+ : undefined,
+ );
+ }, [root, text, format, resolver]);
+}
+
+export function resolveSourceRange(
+ root: HTMLElement,
+ query: string,
+ address: CanvasSearchSource,
+ getTextNodes: (root: HTMLElement) => Node[],
+): { range: Range | null; unavailable: boolean } {
+ const candidates = [
+ root,
+ ...root.querySelectorAll('[data-search-source]'),
+ ].filter(
+ (candidate) =>
+ candidate.getAttribute('data-search-source') === address.hash &&
+ !candidate.closest('[data-preview-active="false"]'),
+ );
+ if (candidates.length !== 1)
+ return { range: null, unavailable: candidates.length > 1 };
+ const candidate = candidates[0];
+ const source = candidate && sources.get(candidate);
+ if (!candidate || !source) return { range: null, unavailable: false };
+ const length = address.length ?? query.length;
+ let matchesAddress = false;
+ for (const match of literalTextMatches(source.text, query)) {
+ if (match.start < address.offset) continue;
+ matchesAddress =
+ match.start === address.offset && match.end === address.offset + length;
+ break;
+ }
+ if (!matchesAddress) {
+ return { range: null, unavailable: true };
+ }
+ const target =
+ source.format === 'markdown'
+ ? markdownSearchTarget(source.text, address.offset, length)
+ : {
+ text: source.text,
+ start: address.offset,
+ end: address.offset + length,
+ };
+ if (!target) return { range: null, unavailable: true };
+ if (source.resolveRange) {
+ return {
+ range: source.resolveRange(target.text, target.start, target.end),
+ unavailable: false,
+ };
+ }
+ const nodes = getTextNodes(candidate);
+ // Never guess an ordinal when a node view or normalization changes text.
+ if (nodes.map((node) => node.textContent ?? '').join('') !== target.text) {
+ return { range: null, unavailable: false };
+ }
+ const range = document.createRange();
+ let offset = 0;
+ let started = false;
+ for (const node of nodes) {
+ const end = offset + (node.textContent?.length ?? 0);
+ if (!started && target.start >= offset && target.start < end) {
+ range.setStart(node, target.start - offset);
+ started = true;
+ }
+ if (started && target.end > offset && target.end <= end) {
+ range.setEnd(node, target.end - offset);
+ return { range, unavailable: false };
+ }
+ offset = end;
+ }
+ return { range: null, unavailable: true };
+}
diff --git a/apps/web/src/hooks/useTextHighlight.test.tsx b/apps/web/src/hooks/useTextHighlight.test.tsx
new file mode 100644
index 000000000..9cd666435
--- /dev/null
+++ b/apps/web/src/hooks/useTextHighlight.test.tsx
@@ -0,0 +1,164 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+
+import { findProjectedRange } from './searchDom';
+import { useTextHighlight } from './useTextHighlight';
+
+let root: Root;
+let host: HTMLDivElement;
+let canvas: HTMLDivElement;
+let preview: HTMLDivElement;
+let highlights: Map>;
+
+function Surface({
+ element,
+ query,
+ activeMatchIndex,
+ getActiveRange,
+}: {
+ element: HTMLElement | null;
+ query: string;
+ activeMatchIndex?: number;
+ getActiveRange?: () => Range | null;
+}) {
+ const { matchCount } = useTextHighlight({
+ container: element,
+ query,
+ activeMatchIndex,
+ getActiveRange,
+ });
+ return ;
+}
+
+beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ highlights = new Map();
+ vi.stubGlobal(
+ 'Highlight',
+ class extends Set {
+ constructor(...ranges: Range[]) {
+ super(ranges);
+ }
+ },
+ );
+ vi.stubGlobal('CSS', { highlights });
+ host = document.createElement('div');
+ canvas = document.createElement('div');
+ preview = document.createElement('div');
+ canvas.textContent = 'canvas needle';
+ preview.textContent = 'preview needle';
+ document.body.append(host, canvas, preview);
+ root = createRoot(host);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ host.remove();
+ canvas.remove();
+ preview.remove();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+it('refreshes an indexed phrase highlight when its text-layer nodes are replaced', async () => {
+ preview.innerHTML = 'needlephrase';
+ const getActiveRange = () =>
+ findProjectedRange(preview, 'needle phrase', 0, 13);
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+ expect(highlights.get('huabu-search')?.size).toBe(1);
+ const previousRange = Array.from(highlights.get('huabu-search') ?? [])[0];
+ preview.innerHTML = 'needlephrase';
+ await act(async () => {
+ await vi.waitFor(() => {
+ const range = Array.from(highlights.get('huabu-search') ?? [])[0];
+ expect(range).not.toBe(previousRange);
+ expect(range?.startContainer).toBe(preview.firstElementChild?.firstChild);
+ expect(range?.toString()).toBe('needlephrase');
+ });
+ });
+ expect(highlights.get('huabu-search')?.size).toBe(1);
+});
+
+it('merges independent canvas and preview highlights and removes only the closing surface', () => {
+ act(() =>
+ root.render(
+ <>
+
+
+
+ >,
+ ),
+ );
+ expect(highlights.get('huabu-search')?.size).toBe(2);
+ act(() =>
+ root.render(
+ <>
+
+
+ >,
+ ),
+ );
+ expect(highlights.get('huabu-search')?.size).toBe(1);
+ expect(
+ Array.from(highlights.get('huabu-search') ?? [])[0]?.startContainer,
+ ).toBe(canvas.firstChild);
+ act(() => root.render());
+ expect(highlights.has('huabu-search')).toBe(false);
+});
+
+it('counts all matches while allocating only the bounded painted ranges', async () => {
+ canvas.textContent = 'needle '.repeat(600);
+ const createRange = vi.spyOn(document, 'createRange');
+ act(() => root.render());
+ expect(host.textContent).toBe('600');
+ expect(highlights.get('huabu-search')?.size).toBe(500);
+ expect(createRange).toHaveBeenCalledTimes(500);
+ canvas.textContent += 'needle';
+ await vi.waitFor(async () => {
+ await act(async () => {
+ await new Promise((resolve) =>
+ requestAnimationFrame(() => resolve()),
+ );
+ });
+
+ expect(host.textContent).toBe('601');
+ });
+ expect(highlights.get('huabu-search')?.size).toBe(500);
+});
+
+it('keeps an occurrence beyond the cap highlighted without increasing the painted range count', () => {
+ canvas.textContent = 'needle '.repeat(600);
+ const createRange = vi.spyOn(document, 'createRange');
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+ expect(host.textContent).toBe('600');
+ expect(highlights.get('huabu-search')?.size).toBe(500);
+ expect(createRange).toHaveBeenCalledTimes(501);
+ expect(
+ Array.from(highlights.get('huabu-search') ?? []).at(-1)?.startOffset,
+ ).toBe(3500);
+ act(() =>
+ root.render(
+ ,
+ ),
+ );
+ expect(highlights.get('huabu-search')?.size).toBe(500);
+ expect(
+ Array.from(highlights.get('huabu-search') ?? []).at(-1)?.startOffset,
+ ).toBe(3507);
+});
diff --git a/apps/web/src/hooks/useTextHighlight.ts b/apps/web/src/hooks/useTextHighlight.ts
index 7d1ab672f..48319768b 100644
--- a/apps/web/src/hooks/useTextHighlight.ts
+++ b/apps/web/src/hooks/useTextHighlight.ts
@@ -27,10 +27,11 @@
import { useEffect, useState } from 'react';
-import { findRanges } from './searchDom';
+import { countMatches, findNthRange, findRanges } from './searchDom';
/** Global registered highlight name. CSS selector: `::highlight(huabu-search)`. */
const HIGHLIGHT_NAME = 'huabu-search';
+const surfaceRanges = new Map();
interface UseTextHighlightOptions {
/**
@@ -39,25 +40,27 @@ interface UseTextHighlightOptions {
* `::highlight()` registration — used when the canvas-wide search
* needs to paint matches on both the canvas root and the expanded
* preview panel at the same time. `null` (or an array of only
- * `null`s) clears the highlight set.
+ * `null`s) removes this surface's ranges without clearing other surfaces.
*/
container: HTMLElement | (HTMLElement | null)[] | null;
/** Needle. Empty string clears the highlight set. */
query: string;
/** Soft cap on Range objects we'll register (across all containers). */
maxRanges?: number;
+ /** Keep the navigated occurrence painted even when it lies beyond the cap. */
+ activeMatchIndex?: number;
+ activeRange?: Range | null;
+ getActiveRange?: () => Range | null;
}
export interface UseTextHighlightResult {
/**
- * Current number of registered match ranges. Updates whenever the
+ * Total searchable occurrences, independent of the highlight cap. Updates whenever the
* MutationObserver re-walks the container, so consumers showing a
* `1/N` counter stay in sync with text that mounts asynchronously
* (pdf.js text layer, Milkdown lazy editor, virtualised lists).
*
- * Capped by `maxRanges` — when the underlying document has more
- * matches than the cap, this number reflects what was registered,
- * not the true total.
+ * `maxRanges` bounds painted Range objects, not the navigation count.
*/
matchCount: number;
}
@@ -87,6 +90,9 @@ export function useTextHighlight({
container,
query,
maxRanges = 500,
+ activeMatchIndex,
+ activeRange: selectedRange,
+ getActiveRange,
}: UseTextHighlightOptions): UseTextHighlightResult {
const [matchCount, setMatchCount] = useState(0);
@@ -110,6 +116,10 @@ export function useTextHighlight({
// Cheaper than a `useMemo` + array-equality check, and avoids the
// pitfall of `[a, b]` deps being a fresh array each render.
const depKey = liveContainers.map(elementId).join('|');
+ const outlyingMatchIndex =
+ activeMatchIndex !== undefined && activeMatchIndex >= maxRanges
+ ? activeMatchIndex
+ : null;
useEffect(() => {
const HighlightCtor: typeof Highlight | undefined = (
@@ -122,47 +132,69 @@ export function useTextHighlight({
// No-container / no-query / no-API → make sure count is zeroed
// even when the API isn't available (jsdom in tests).
if (liveContainers.length === 0 || !query) {
- highlights?.delete(HIGHLIGHT_NAME);
setMatchCount(0);
return;
}
- if (!HighlightCtor || !highlights) {
- // API unavailable — still surface a count so the find bar can
- // advance, since the fallback `findNthRange` walk uses the same
- // DOM independently of the registered highlight.
- let initial = 0;
- for (const c of liveContainers) {
- initial += findRanges(c, query, maxRanges - initial).length;
- if (initial >= maxRanges) break;
- }
- setMatchCount(initial);
- return;
- }
+ const surface = Symbol('search-surface');
+ const publish = (): void => {
+ if (!HighlightCtor || !highlights) return;
+ const ranges = Array.from(surfaceRanges.values()).flat();
+ if (ranges.length === 0) highlights.delete(HIGHLIGHT_NAME);
+ else highlights.set(HIGHLIGHT_NAME, new HighlightCtor(...ranges));
+ };
const apply = (): void => {
const ranges: Range[] = [];
+ let count = 0;
+ const candidate = getActiveRange?.() ?? selectedRange;
+ let activeRange =
+ candidate &&
+ !candidate.collapsed &&
+ liveContainers.some(
+ (container) =>
+ container.contains(candidate.startContainer) &&
+ container.contains(candidate.endContainer),
+ )
+ ? candidate
+ : null;
for (const c of liveContainers) {
+ const containerCount = countMatches(c, query);
+ if (
+ HighlightCtor &&
+ highlights &&
+ !activeRange &&
+ outlyingMatchIndex !== null &&
+ outlyingMatchIndex >= count &&
+ outlyingMatchIndex < count + containerCount
+ ) {
+ activeRange = findNthRange(c, query, outlyingMatchIndex - count);
+ }
+ count += containerCount;
const remaining = maxRanges - ranges.length;
- if (remaining <= 0) break;
+ if (remaining <= 0 || !HighlightCtor || !highlights) continue;
// `findRanges` checks `document.contains` implicitly via
// `createTreeWalker`; a container that detached between
// renders just yields zero ranges.
ranges.push(...findRanges(c, query, remaining));
}
+ if (
+ activeRange &&
+ !ranges.some(
+ (range) =>
+ range.startContainer === activeRange.startContainer &&
+ range.startOffset === activeRange.startOffset &&
+ range.endContainer === activeRange.endContainer &&
+ range.endOffset === activeRange.endOffset,
+ )
+ ) {
+ if (ranges.length < maxRanges) ranges.push(activeRange);
+ else if (ranges.length) ranges[ranges.length - 1] = activeRange;
+ }
// Only update state when the count actually changes — avoids
// a render storm during pdf.js's per-page text-layer mounts.
- setMatchCount((prev) => (prev === ranges.length ? prev : ranges.length));
- if (ranges.length === 0) {
- highlights.delete(HIGHLIGHT_NAME);
- return;
- }
- // Highlight constructor accepts an iterable of Range.
- highlights.set(
- HIGHLIGHT_NAME,
- new HighlightCtor(
- ...(ranges as unknown as ConstructorParameters),
- ),
- );
+ setMatchCount((prev) => (prev === count ? prev : count));
+ if (HighlightCtor && highlights) surfaceRanges.set(surface, ranges);
+ publish();
};
apply();
@@ -198,13 +230,21 @@ export function useTextHighlight({
return () => {
if (scheduled) cancelAnimationFrame(scheduled);
for (const o of observers) o.disconnect();
- highlights.delete(HIGHLIGHT_NAME);
+ surfaceRanges.delete(surface);
+ publish();
};
// `liveContainers` is rebuilt every render but `depKey` collapses
// it into a primitive identity string, so the effect only re-runs
// when an actual element comes/goes.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [depKey, query, maxRanges]);
+ }, [
+ depKey,
+ query,
+ maxRanges,
+ outlyingMatchIndex,
+ selectedRange,
+ getActiveRange,
+ ]);
return { matchCount };
}
diff --git a/apps/web/src/i18n/resources/en/common.json b/apps/web/src/i18n/resources/en/common.json
index 3d615c152..d1ba15282 100644
--- a/apps/web/src/i18n/resources/en/common.json
+++ b/apps/web/src/i18n/resources/en/common.json
@@ -530,7 +530,19 @@
"previousMatch": "Previous match",
"nextMatch": "Next match",
"clear": "Clear search",
- "searching": "Searching"
+ "searching": "Searching",
+ "noMatches": "No matches",
+ "searchingNoteContents": "Searching note contents…",
+ "resultsTruncated": "The result set only contains a subset of all matches. Be more specific in your search to narrow down the results.",
+ "conversationMatchNotVisible": "Cannot precisely locate this match in the conversation. It may only exist in source text, or the content may have changed.",
+ "previewMatchNotVisible": "Cannot precisely locate this match in the preview. It may only exist in source text, or the content may have changed.",
+ "fields": {
+ "label": "Title",
+ "summary": "Summary",
+ "keywords": "Keywords",
+ "content": "Content",
+ "conversation": "Conversation"
+ }
},
"editor": {
"readOnlyContent": "Read-only content",
diff --git a/apps/web/src/i18n/resources/zh-CN/common.json b/apps/web/src/i18n/resources/zh-CN/common.json
index 3e4931ccb..37d9c3ddc 100644
--- a/apps/web/src/i18n/resources/zh-CN/common.json
+++ b/apps/web/src/i18n/resources/zh-CN/common.json
@@ -530,7 +530,19 @@
"previousMatch": "上一个匹配项",
"nextMatch": "下一个匹配项",
"clear": "清除搜索",
- "searching": "正在搜索"
+ "searching": "正在搜索",
+ "noMatches": "没有匹配结果",
+ "searchingNoteContents": "正在搜索笔记正文…",
+ "resultsTruncated": "当前仅显示部分匹配结果,请使用更具体的搜索内容缩小范围。",
+ "conversationMatchNotVisible": "无法在当前对话中精确定位此匹配;它可能仅存在于源码中,或内容已更新。",
+ "previewMatchNotVisible": "无法在当前预览中精确定位此匹配;它可能仅存在于源码中,或内容已更新。",
+ "fields": {
+ "label": "标题",
+ "summary": "摘要",
+ "keywords": "关键词",
+ "content": "正文",
+ "conversation": "对话"
+ }
},
"editor": {
"readOnlyContent": "只读内容",
diff --git a/apps/web/src/pages/playground/NodeToolbarPlaygroundPage.tsx b/apps/web/src/pages/playground/NodeToolbarPlaygroundPage.tsx
index c2664f627..457f10383 100644
--- a/apps/web/src/pages/playground/NodeToolbarPlaygroundPage.tsx
+++ b/apps/web/src/pages/playground/NodeToolbarPlaygroundPage.tsx
@@ -462,6 +462,7 @@ function Specimen({ type: initialType }: { type: CanvasNodeType }) {
{
isOpen: false,
});
});
+
+ it('addresses a result without changing canvas search and fences stale navigation completion', () => {
+ const state = usePreviewSearchStore.getState;
+ useSearchStore.setState({ query: 'canvas query' });
+ state().reveal('note-1', 'needle', 2);
+ const first = state().navigationRequest;
+ expect(state()).toMatchObject({
+ nodeId: 'note-1',
+ query: 'needle',
+ isOpen: true,
+ focusInput: false,
+ navigationRequest: { occurrenceIndex: 2 },
+ });
+ state().reveal('note-2', 'needle', 3);
+ const second = state().navigationRequest;
+ expect(second?.nonce).toBeGreaterThan(first?.nonce ?? 0);
+ state().consumeNavigation(first?.nonce ?? 0);
+ expect(state().navigationRequest).toBe(second);
+ state().consumeNavigation(second?.nonce ?? 0);
+ expect(state().navigationRequest).toBeNull();
+ expect(useSearchStore.getState().query).toBe('canvas query');
+ state().open('note-2');
+ expect(state().focusInput).toBe(true);
+ });
});
diff --git a/apps/web/src/store/previewSearchStore.ts b/apps/web/src/store/previewSearchStore.ts
index 50069083d..abe9b078e 100644
--- a/apps/web/src/store/previewSearchStore.ts
+++ b/apps/web/src/store/previewSearchStore.ts
@@ -3,20 +3,113 @@
import { create } from 'zustand';
+import type { CanvasSearchSource } from '@huabu/shared';
+
+const renderers = new Map>();
+
+function latestRenderer(nodeId: string): symbol | null {
+ return (
+ Array.from(renderers.get(nodeId) ?? [])
+ .filter(([, entry]) => entry.active)
+ .at(-1)?.[0] ?? null
+ );
+}
+
interface PreviewSearchState {
nodeId: string | null;
query: string;
isOpen: boolean;
+ focusInput: boolean;
+ rendererOwner: symbol | null;
+ navigationRequest: {
+ occurrenceIndex: number;
+ nonce: number;
+ source?: CanvasSearchSource;
+ } | null;
+ navigationSequence: number;
open: (nodeId: string) => void;
+ reveal: (
+ nodeId: string,
+ query: string,
+ occurrenceIndex: number,
+ source?: CanvasSearchSource,
+ ) => void;
+ registerRenderer: (nodeId: string, owner: symbol) => () => void;
+ consumeNavigation: (nonce: number) => void;
close: () => void;
setQuery: (query: string) => void;
}
-export const usePreviewSearchStore = create((set) => ({
+export const usePreviewSearchStore = create((set, get) => ({
nodeId: null,
query: '',
isOpen: false,
- open: (nodeId) => set({ nodeId, query: '', isOpen: true }),
- close: () => set({ nodeId: null, query: '', isOpen: false }),
- setQuery: (query) => set({ query }),
+ focusInput: false,
+ navigationRequest: null,
+ navigationSequence: 0,
+ rendererOwner: null,
+ registerRenderer: (nodeId, owner) => {
+ let owners = renderers.get(nodeId);
+ if (!owners) {
+ owners = new Map();
+ renderers.set(nodeId, owners);
+ }
+ const registration = { active: true };
+ owners.delete(owner);
+ owners.set(owner, registration);
+ if (get().nodeId === nodeId) set({ rendererOwner: owner });
+ return () => {
+ registration.active = false;
+ // Effect replay renews the registration before this disposal checkpoint.
+ queueMicrotask(() => {
+ const current = renderers.get(nodeId);
+ if (current?.get(owner) !== registration) return;
+ current.delete(owner);
+ if (current.size === 0) renderers.delete(nodeId);
+ const state = get();
+ if (state.nodeId !== nodeId || state.rendererOwner !== owner) return;
+ const remaining = latestRenderer(nodeId);
+ if (remaining) set({ rendererOwner: remaining });
+ else state.close();
+ });
+ };
+ },
+ open: (nodeId) =>
+ set({
+ nodeId,
+ query: '',
+ isOpen: true,
+ focusInput: true,
+ navigationRequest: null,
+ rendererOwner: latestRenderer(nodeId),
+ }),
+ reveal: (nodeId, query, occurrenceIndex, source) =>
+ set((state) => {
+ const nonce = state.navigationSequence + 1;
+ return {
+ nodeId,
+ query: query.trim(),
+ isOpen: true,
+ focusInput: false,
+ navigationSequence: nonce,
+ navigationRequest: { occurrenceIndex, nonce, source },
+ rendererOwner: latestRenderer(nodeId),
+ };
+ }),
+ consumeNavigation: (nonce) =>
+ set((state) =>
+ state.navigationRequest?.nonce === nonce
+ ? { navigationRequest: null }
+ : {},
+ ),
+ close: () =>
+ set({
+ nodeId: null,
+ query: '',
+ isOpen: false,
+ focusInput: false,
+ navigationRequest: null,
+ rendererOwner: null,
+ }),
+ setQuery: (query) => set({ query, navigationRequest: null }),
}));
diff --git a/apps/web/src/store/previewWorkspace/actions.ts b/apps/web/src/store/previewWorkspace/actions.ts
index f15466c00..6e446ff00 100644
--- a/apps/web/src/store/previewWorkspace/actions.ts
+++ b/apps/web/src/store/previewWorkspace/actions.ts
@@ -16,7 +16,7 @@ import {
export function openPreviewNode(
nodeId: string,
- options?: { transient?: boolean },
+ options?: { transient?: boolean; focusEditor?: boolean },
): string {
const canvas = useCanvasStore.getState();
const previousNodeId = selectActiveNodeId(
@@ -37,8 +37,14 @@ export function openPreviewNode(
{ transient: options?.transient ?? true },
);
const openedNode = canvas.nodes.find((node) => node.id === nodeId);
- if (tabId && openedNode?.type === 'note') {
+ if (tabId && openedNode?.type === 'note' && options?.focusEditor !== false) {
usePreviewWorkspaceStore.getState().requestNodeFocus(tabId);
+ } else if (tabId && options?.focusEditor === false) {
+ const preview = usePreviewWorkspaceStore.getState();
+ const request = preview.nodeFocusRequest;
+ if (request?.tabId === tabId) {
+ preview.consumeNodeFocusRequest(tabId, request.nonce);
+ }
}
return tabId;
}
diff --git a/apps/web/src/store/searchStore.ts b/apps/web/src/store/searchStore.ts
index cfc849f26..14e771d90 100644
--- a/apps/web/src/store/searchStore.ts
+++ b/apps/web/src/store/searchStore.ts
@@ -147,20 +147,23 @@ export const useSearchStore = create((set, get) => ({
},
setQuery: (query) => {
- set({ query });
- const trimmed = query.trim();
- cancelInFlight();
- if (!trimmed) {
- set({
- results: [],
- isStreaming: false,
- truncated: false,
- contentPhase: false,
- error: null,
- collapsedNodeIds: new Set(),
- });
+ const changed = query.trim() !== get().query.trim();
+ if (!changed) {
+ set({ query });
return;
}
+ const trimmed = query.trim();
+ cancelInFlight();
+ set({
+ query,
+ results: [],
+ isStreaming: false,
+ truncated: false,
+ contentPhase: false,
+ error: null,
+ collapsedNodeIds: new Set(),
+ });
+ if (!trimmed) return;
debounceHandle = setTimeout(() => {
void runSearch(trimmed, set, get);
}, DEBOUNCE_MS);
@@ -175,7 +178,7 @@ export const useSearchStore = create((set, get) => ({
const sameLength = prev.length === nodeTypes.length;
const sameMembers = sameLength && prev.every((t, i) => t === nodeTypes[i]);
if (sameMembers) return;
- set({ nodeTypes });
+ set({ nodeTypes, results: [], isStreaming: false, truncated: false });
const trimmed = get().query.trim();
cancelInFlight();
if (!trimmed) return;
diff --git a/docs/architecture/note-node.md b/docs/architecture/note-node.md
index cd98ab530..f4cd0838f 100644
--- a/docs/architecture/note-node.md
+++ b/docs/architecture/note-node.md
@@ -33,7 +33,7 @@ The shared NoteContentViewport keeps its `h-full` constraint in both scrollbar m
**Rendering** — the canvas card mounts the read-only `MilkdownPreview`; the expanded panel mounts the editable `MilkdownEditor`, which also offers a raw-Markdown source mode alongside WYSIWYG. See §4.
-**Searching** — expanded-preview find marks the WYSIWYG document or raw-Markdown editor as its searchable content root. Editor chrome, including Crepe's mounted-but-hidden slash menu and Huabu's floating toolbar and provenance controls, is outside that boundary and does not contribute matches. The shared DOM walker also rejects hidden and explicitly excluded text so counting, highlighting, and next/previous navigation agree.
+**Searching** — each renderer scopes its own searchable document without excluding sibling titles, plain text, user prompts, or quoted comments from a larger Canvas or conversation search. Editor chrome, including Crepe's mounted-but-hidden slash menu and Huabu's floating toolbar and provenance controls, does not contribute matches. Activating a Canvas content-search result seeds the preview-local find session with a normalized query and exact source address, waits for the editor to mount, and scrolls to the highlighted match without focusing the input or changing editing mode. WYSIWYG maps visible source positions through ProseMirror; raw Markdown uses CodeMirror's complete document for matching and scroll effects to render off-viewport positions before acknowledging visibility. Neither path changes the editor selection. Source-only syntax is unavailable in WYSIWYG but remains locatable when already in source mode; see the [Preview Workspace navigation contract](./preview-workspace.md#6-focus-and-opening-position).
**Saving** — an edit calls `updateNodeData(id, { content })`, which dispatches `UPDATE_NODE_DATA` → `MERGE_NODE_DATA`. There is **no debounce**: every editor `onChange` writes through. Concurrency is handled at the server by rev-CAS, which rejects a stale write with `409 NODE_CONTENT_CONFLICT` rather than merging it.
diff --git a/docs/architecture/preview-workspace.md b/docs/architecture/preview-workspace.md
index 279e04fe8..4818675e5 100644
--- a/docs/architecture/preview-workspace.md
+++ b/docs/architecture/preview-workspace.md
@@ -211,7 +211,37 @@ Single and batch tab closing share one removal primitive: resolve the removed ID
Editor focus is a runtime-only `{ tabId, nonce }` request. Only the addressed active tab receives it, and its renderer consumes it after focus succeeds so remounting cannot replay stale intent.
-Question conversation positioning is a runtime-only `{ tabId, position, nonce }` request where `position` is `last-user` or `bottom`. `MessageList` consumes an explicit request after history hydration and successful positioning. Ordinary Chat activation (warm switch, cold mount, close/reopen or opening after reload) defaults to the latest content in the recent-turn window, not an older saved offset. Each group supplies an activation identity that changes only when its active tab changes; repeated clicks on the active tab, other-group focus and ordinary rerenders do not reset the window. Explicit positioning and conversation-search navigation take precedence over the ordinary default. Search addresses the destination thread, reveals its cached history before DOM match navigation, and suspends following/compaction.
+Question conversation positioning is a runtime-only `{ tabId, position, nonce }` request where `position` is `last-user` or `bottom`. `MessageList` consumes an explicit request after history hydration and successful positioning. Ordinary Chat activation (warm switch, cold mount, close/reopen or opening after reload) defaults to the latest content in the recent-turn window, not an older saved offset. Each group supplies an activation identity that changes only when its active tab changes; repeated clicks on the active tab, other-group focus and ordinary rerenders do not reset the window. Explicit positioning and conversation-search navigation take precedence over the ordinary default. Search addresses the destination thread, reveals its cached turns, and suspends following/compaction. An explicit jump also pages older server history before resolving its source address; a recent-only match must not stand in for an older result. Paging stops on completion, cancellation, timeout, page error, or tab deactivation. Unavailable matches retain the localized failure hint instead of silently jumping to the first occurrence.
+
+Activating a Canvas content match opens the node's transient preview and hands its normalized query and source address to the preview-local search store through a nonce-addressed navigation request. The input retains its raw spelling, but the streamed request, highlights, and handoff use the same trimmed query. Changing the effective query or filters invalidates old rows synchronously, before debounce, so an old row cannot activate under new input. Search opens previews with `focusEditor: false`, suppressing both a new Note editor-focus request and a stale request for the destination tab; ordinary Note opening retains its editor-focus behavior. This opens the find bar without focusing its input or stealing keyboard ownership from the Canvas search results. Canvas and preview queries remain independent after this initial handoff.
+
+Preview search registers individual renderer owners rather than disposing by node ID alone. Only the current owner processes a handoff; registration release settles at a microtask checkpoint so StrictMode effect replay can renew the same registration without destroying a cold request. Disposing an old renderer cannot close a newer same-node owner, while final real disposal and node changes still close abandoned sessions. Completion consumes only the matching nonce.
+
+Manual Next/Previous navigation also owns its adapter cancellation function. A replacement jump, query/session/renderer change, adapter replacement, or unmount cancels the old work; delayed completions must still match the active navigation identity, query, session sequence, renderer owner, and adapter registration before updating counters, highlights, or notifications.
+
+Streaming result updates preserve the active row by its stable match key, including field regrouping that changes row indices. Query, scope, and node-type filter changes reset selection; appended results do not move keyboard focus to the first header. Renderer-local document boundaries exclude editor chrome without excluding searchable sibling surfaces in the Canvas or conversation root.
+
+Content and conversation matches retain their raw-source ordinals for result ordering, but navigation uses `{ hash, offset, length? }`: a SHA-256 identity of the exact source segment and its original UTF-16 match span, including case-folding length changes. Server, DOM, and PDF scanners reuse the same literal matcher rather than treating lowercased-string offsets as original-source offsets. User prompts, quoted passages/comments, and assistant Markdown register their own sources; conversation navigation never treats a raw whole-thread ordinal as a rendered DOM ordinal. Markdown mapping reuses the shared GFM parser and projects visible leaf text. Milkdown verifies that projection against its ProseMirror document, maps text offsets through model positions into DOM positions, and verifies the resulting Range without changing the editor selection; node-view chrome cannot shift the address of a later paragraph. Other registered text surfaces require an exact DOM projection. Hidden link destinations, syntax, changed sources, ambiguous identical segments, and unsupported rendering transformations report unavailable rather than choosing another occurrence or switching to source mode. Note and Office Markdown use the same registration path. Sandboxed Web and media previews have no source-position bridge and therefore report unavailable for exact content navigation.
+
+The scheduler waits for both asynchronous content and a laid-out Range. Scrolling uses the actual Range coordinates in each nested scrollable or clipping ancestor, not the height of its parent paragraph, and rechecks the viewport intersection before reporting success. Zero-size or still-clipped targets remain pending until layout is ready or the localized timeout notification fires.
+
+Source hashing uses Web Crypto when available and a lazy-loaded SHA-256 implementation otherwise, preserving the server digest on supported ordinary-HTTP deployments. Original-span validation uses the shared whole-source lowercase matcher, including contextual Unicode mappings and complete-character expansion boundaries. Bounded Space `SEARCH` responses reuse the canonical Canvas match schema instead of duplicating the wire shape.
+
+The literal matcher advances by the full folded query length only after accepting a complete-source-boundary match. Rejected expansion-boundary candidates advance by one folded UTF-16 unit so a later valid candidate is not skipped; accepted matches remain non-overlapping.
+
+When a Note is already in raw Markdown mode, CodeMirror registers the unprojected source and a preview-local indexed adapter. Matching and counters use the complete editor document, including virtualized content. Navigation requests the exact source position through CodeMirror's scroll effect, then resolves and verifies the rendered Range without changing selection, content, focus, or editing mode. Markdown syntax and link URLs are visible matches in this mode; their unavailable behavior in rendered Markdown does not apply.
+
+PDF search handoff waits for its indexed adapter to register with the current query identity rather than treating a pending or previous-query adapter as a DOM-only renderer. The adapter remains non-navigable until the document has loaded and text indexing has settled. Extracted pages and processed-page progress publish atomically, so readiness cannot expose a partial match list and consume a pending ordinal too early. Canvas source addresses are accepted only when their source hash and the complete whitespace-normalized Markdown projection agree with the PDF text index; otherwise exact navigation reports unavailable. Indexed page offsets map across text-layer spans, including inferred whitespace, and the adapter acknowledges completion only after the actual Range is visible. Out-of-range requests never clamp, and missing page occurrences never fall back to the first match.
+
+Terminal PDF source/load errors settle the matching handoff immediately with the localized unavailable notification. Failures belong to their source, so a replacement source does not inherit an old terminal error. Adapter registrations expose a current-registration check; retired snapshots cannot process or complete a handoff while a replacement registration is propagating through React context. Waiting for adapter registration or indexing is also bounded by an eight-second readiness watchdog; unregistered or indefinitely loading adapters cannot leave the request pending forever.
+
+Question `content` matches carry ordinals local to the plain-text node body, not the complete conversation. After revealing history, navigation scopes these ordinals to the unique user prompt whose complete trimmed content equals that body; prompt text has a dedicated DOM marker excluding quoted comments, skills, interpretation text, and attachments. Missing or ambiguous source prompts use the existing unavailable-match notification rather than selecting a different message. Conversation-tier matches use source identities and offsets instead.
+
+DOM search handoffs wait for the requested occurrence instead of accepting the first match while asynchronous Markdown is still mounting. A missing requested occurrence uses the existing timeout notification rather than silently navigating to a different occurrence.
+
+Preview find counters and Next/Previous use the complete searchable occurrence count. The shared text walker counts matches without allocating a Range for each one; the highlight layer independently caps painted ranges at 500 per preview surface. A result beyond that cap still shows its correct ordinal and navigates across the full result set; its active range replaces the last painted range so the destination remains highlighted without increasing the cap.
+
+The shared CSS Custom Highlight registration merges ranges owned by all active search surfaces. Clearing or unmounting an inactive find bar removes only its own ranges, so a Canvas search or another preview cannot accidentally erase the current document's highlights.
Every scrollable Preview renderer uses a runtime cache: Chat keys by conversation-owner Canvas plus `threadId`, while ordinary nodes key by target Canvas plus `nodeId`. Chat remembers an intentional reading message and viewport offset without overwriting them on programmatic bottom navigation. When available on activation, Return to previous reading position reveals the cached target and restores it best effort, outside the activation path. An unavailable target is reported explicitly; this action does not fetch history solely for recovery or persist across reload. Live-to-history ID replacement can make an old bookmark unavailable. Prepending earlier content anchors the first visible message and compensates for bounded late resizing unless user input takes control. Note, PDF, Office, and Web reader renderers keep automatic offset restoration through the shared hook because the outer panel is intentionally non-scrolling. Hidden retained tabs ignore Activity-driven scroll events and Effect cleanup. The cache removes an offset after the final target is closed, replaced, or invalidated, and clears unreferenced offsets when a Canvas layout is deleted or evicted from the persisted MRU index.
diff --git a/docs/architecture/web-architecture.md b/docs/architecture/web-architecture.md
index 0a08e7cc7..fd28c91a1 100644
--- a/docs/architecture/web-architecture.md
+++ b/docs/architecture/web-architecture.md
@@ -3,7 +3,7 @@
> Structure, dependency rules, and conventions for the frontend. The point of
> this doc is the **layering rules** below — not an exhaustive file listing
> (those rot fast; `ls` the dir for the current files).
-> Last updated: 2026-10-09
+> Last updated: 2026-10-20
---
@@ -67,12 +67,32 @@ What stays in `apps/web/src/handler/canvasCommand/`:
## 4. Conventions
+Common DropdownMenu action lists expose a labelled `menu` linked to their trigger. Opening focuses the first enabled item; ArrowDown/ArrowUp on the trigger open at the first/last enabled item, and ArrowDown/ArrowUp/Home/End inside the menu move focus while skipping disabled items. Tab dismisses without blocking native traversal, Escape returns focus to the trigger, and ArrowRight/ArrowLeft enter/leave submenus. Hover-opened submenus do not steal focus. Menu items retain native button/link activation. Form and layout panels declare `contentRole="dialog"` so text editing and ordinary tab order remain native; consumers that already own their menu/dialog semantics and keyboard navigation declare `contentRole="presentation"` to avoid nested menus or duplicate handlers. Popover forwards the root DOM semantics and keyboard handler without adding layout wrappers.
+
+Focusing the first or last enabled menu item also scrolls it into view, including ArrowUp opening a long, scrollable menu at its last option.
+
+CanvasZoomMenu owns a mixed input/action dialog rather than an arrow-only action menu. Its five action items explicitly retain native Tab stops; Tab traverses enabled controls and the dialog dismisses at its first/last boundary.
+
+On submenu triggers, ArrowDown and ArrowUp continue moving through the parent menu rather than opening the child. ArrowRight enters the child and focuses its first enabled item, including when hover has already opened it without moving focus.
+
+Canvas search synchronizes its active row when a result receives DOM focus. Arrow navigation from a result moves focus to the newly active row after Virtuoso reveals it, so Enter activates the same row that has focus. Navigation from the search input retains input focus and uses the active row for confirmation; browsing continues to reveal Canvas nodes without opening Preview tabs until explicit activation.
+
+Virtualized search navigation captures result-focus ownership before scrolling. When the focused row is unmounted, completion restores focus to the destination only if focus fell to the document body; it does not override focus deliberately moved to another control, and superseded scroll completions cannot restore stale focus.
+
+Canvas search groups matches by node and then by field in first-seen order. Each field has one localized heading above its contiguous matches, rather than a repeated label on every match. Field headings are not extra keyboard stops; each snippet remains independently focusable and activatable, with its field included in its accessible name. Node counts continue to count matches rather than fields.
+
+Canvas search uses the shared `search` translation keys for empty, streaming, truncated-result, and conversation-match timeout messages. Result-field identifiers remain unchanged in the wire data and map to localized display labels for title, summary, keywords, content, and conversation. Switching language updates result labels and status messages without resetting the search.
+
Common Select menus use Popover's element-anchored Floating UI positioning instead of snapshot coordinates. The panel remains hidden until positioning completes, follows its trigger during scrolling and layout changes, and flips to the opposite side when space is insufficient rather than clamping over the trigger. Opening refresh callbacks run in the event handler, not a React state updater, so Strict Mode does not duplicate discovery requests. Agent Settings retain the loaded Profile catalogue during refresh; only the initial load replaces it with a loading row, preventing selector-trigger layout shifts when opening the Utility Agent menu.
The chat input surface uses 12px top and horizontal padding and 8px bottom padding to keep the selector/send row compact while aligning the source area and textarea to the same content edges. The Agent selector below the input omits its decorative chevron in both editable and locked states without changing menu interaction; session selectors inside the input retain their chevrons.
Lucide UI icons use the shared `--icon-stroke-width: 2` token in `index.css`. A low-specificity base-layer rule targets only `svg.lucide[stroke-width='2']`, the attribute Lucide emits by default; ordinary callers omit `strokeWidth`. Non-default `strokeWidth` props, component-specific CSS, and inline styles remain overrides, and non-Lucide SVG artwork is unaffected. To explicitly retain Lucide's original width of 2 independently of future token changes, use `style={{ strokeWidth: 2 }}` because its DOM attribute is indistinguishable from the library default. Shared menus inherit this default rather than forcing a separate stroke width. Icon sizes and semantic foreground/hover colors remain owned by each surface.
+Canvas toolbar icon strokes intentionally use two visual tiers: the main toolbar uses the shared 2px stroke, while node and selection context toolbars use the lighter 1.65px stroke defined in `NodeToolbar.css`. This is a deliberate hierarchy, not a consistency defect. Text-format controls retain their explicit 2px normal and 2.25px pressed strokes, and custom line-style previews retain strokes that illustrate the underlying content.
+
+Main Canvas navigation and node-placement controls expose `aria-pressed` from the same active-tool or pending-placement state that drives their visual highlight. SplitSelect accepts an optional `primaryPressed` state for its primary action without changing the menu trigger's disclosure semantics; ordinary SplitSelect consumers omit it. Touch and pen Sketch mode controls retain their existing draw/erase pressed states.
+
The Space-menu chevron and Layers disclosure icons in the Canvas header share 16px dimensions, the shared Lucide stroke width, and the existing `text-fg-subtle` theme color in both light and dark modes; hovering a control uses `fg-default`. Layers uses PanelLeft to expand and PanelLeftClose to collapse. No additional color token is introduced, and the existing text hierarchy and other icon surfaces are unchanged.
Layers tree items and Canvas search-result headers share `LayerNodeIcon`: 14px canonical node icons, Office format-specific icons, Sketch stroke thumbnails (or Pencil for empty sketches), and Question lifecycle status dots. Search icons subscribe to their live Canvas node so format, strokes, status, and viewed updates appear without rerunning the query. Missing live nodes retain the generic search-result type icon; edge results always use Spline rather than a node icon. Lucide icons use the shared stroke width without local overrides; Sketch thumbnails retain their independent stroke-preview rendering.
diff --git a/packages/shared/src/canvas-engine/index.ts b/packages/shared/src/canvas-engine/index.ts
index 66e41099a..92bacf626 100644
--- a/packages/shared/src/canvas-engine/index.ts
+++ b/packages/shared/src/canvas-engine/index.ts
@@ -141,7 +141,7 @@ export {
type FrameResponsiveMetrics,
type FrameResponsiveTier,
} from './frame/design.js';
-export { stripMarkdown } from './utils/markdown.js';
+export { stripMarkdown, markdownSearchTarget } from './utils/markdown.js';
export { structuredFrameResizeScale } from './frame/resize.js';
export { normalizeMathDelimiters } from './provenance/normalizeMathDelimiters.js';
export {
diff --git a/packages/shared/src/canvas-engine/utils/markdown.test.ts b/packages/shared/src/canvas-engine/utils/markdown.test.ts
index 803271430..a4856474a 100644
--- a/packages/shared/src/canvas-engine/utils/markdown.test.ts
+++ b/packages/shared/src/canvas-engine/utils/markdown.test.ts
@@ -3,7 +3,7 @@
import { describe, expect, it } from 'vitest';
-import { stripMarkdown } from './markdown.js';
+import { stripMarkdown, markdownSearchTarget } from './markdown.js';
describe('stripMarkdown', () => {
it.each([
@@ -46,4 +46,45 @@ describe('stripMarkdown', () => {
])('projects %j without corrupting its content', (input, expected) => {
expect(stripMarkdown(input)).toBe(expected);
});
+
+ describe('markdownSearchTarget', () => {
+ it('addresses visible text without counting hidden link destinations', () => {
+ const source =
+ '[needle](https://example.test/needle)\n\nParagraph needle';
+ expect(
+ markdownSearchTarget(source, source.indexOf('needle', 10), 6),
+ ).toBeNull();
+ expect(
+ markdownSearchTarget(source, source.lastIndexOf('needle'), 6),
+ ).toEqual({
+ text: 'needleParagraph needle',
+ start: 16,
+ end: 22,
+ });
+ });
+
+ it.each([
+ ['```needle\nneedle\n```', 3],
+ ['`needle`', 0],
+ ['', 2],
+ ['[label][ref]\n\n[ref]: https://needle.test', 27],
+ ['an & entity', 4],
+ ])(
+ 'does not treat hidden or transformed source syntax as visible: %s',
+ (source, offset) => {
+ expect(markdownSearchTarget(source, offset, 6)).toBeNull();
+ },
+ );
+
+ it('distinguishes a code fence language from its literal body', () => {
+ const source = '```needle\nneedle\n```';
+ expect(
+ markdownSearchTarget(source, source.lastIndexOf('needle'), 6),
+ ).toEqual({
+ text: 'needle',
+ start: 0,
+ end: 6,
+ });
+ });
+ });
});
diff --git a/packages/shared/src/canvas-engine/utils/markdown.ts b/packages/shared/src/canvas-engine/utils/markdown.ts
index 273590018..6588d5c1c 100644
--- a/packages/shared/src/canvas-engine/utils/markdown.ts
+++ b/packages/shared/src/canvas-engine/utils/markdown.ts
@@ -11,7 +11,10 @@ interface MarkdownNode {
alt?: string | null;
checked?: boolean | null;
children?: MarkdownNode[];
- position?: { start: { line: number }; end: { line: number } };
+ position?: {
+ start: { line: number; offset?: number };
+ end: { line: number; offset?: number };
+ };
}
const BLOCK_CONTAINERS = new Set(['root', 'blockquote', 'list', 'listItem']);
@@ -71,3 +74,55 @@ export function stripMarkdown(input: string): string {
}),
).trim();
}
+
+/**
+ * Resolve a literal source match into the parser's visible text projection.
+ * Hidden syntax and transformations without an exact source mapping fail closed.
+ */
+export function markdownSearchTarget(
+ source: string,
+ offset: number,
+ length: number,
+): { text: string; start: number; end: number } | null {
+ const tree: MarkdownNode = fromMarkdown(source, {
+ extensions: [gfm()],
+ mdastExtensions: [gfmFromMarkdown()],
+ });
+ let text = '';
+ let start: number | undefined;
+ const visit = (node: MarkdownNode): void => {
+ if (['definition', 'html', 'image', 'imageReference'].includes(node.type))
+ return;
+ if (node.value !== undefined) {
+ const from = node.position?.start.offset;
+ const to = node.position?.end.offset;
+ if (from !== undefined && to !== undefined && node.value) {
+ const raw = source.slice(from, to);
+ let contentOffset = raw === node.value ? 0 : -1;
+ if (node.type === 'inlineCode') {
+ const delimiter = raw.match(/^`+/)?.[0].length ?? 0;
+ if (raw.slice(delimiter, -delimiter) === node.value)
+ contentOffset = delimiter;
+ } else if (node.type === 'code' && /^(?:`{3,}|~{3,})/.test(raw)) {
+ const bodyStart = raw.indexOf('\n') + 1;
+ if (
+ raw.slice(bodyStart, bodyStart + node.value.length) === node.value
+ )
+ contentOffset = bodyStart;
+ }
+ const relative = offset - from - contentOffset;
+ if (
+ contentOffset >= 0 &&
+ relative >= 0 &&
+ relative + length <= node.value.length
+ )
+ start = text.length + relative;
+ }
+ text += node.value;
+ return;
+ }
+ for (const child of node.children ?? []) visit(child);
+ };
+ visit(tree);
+ return start === undefined ? null : { text, start, end: start + length };
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index db3668103..a32c8d30c 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -6,6 +6,7 @@ export * from './types/api/index.js';
export * from './types/agent/index.js';
export * from './utils/id.js';
export * from './utils/chat-input.js';
+export * from './utils/text-search.js';
export * from './utils/agent-icon.js';
export * from './utils/spatial/index.js';
export * from './utils/artifact-url.js';
diff --git a/packages/shared/src/types/api/canvas-search.ts b/packages/shared/src/types/api/canvas-search.ts
index 95095b972..5b315fc51 100644
--- a/packages/shared/src/types/api/canvas-search.ts
+++ b/packages/shared/src/types/api/canvas-search.ts
@@ -106,7 +106,17 @@ export type CanvasSearchRequest = z.infer;
* untruncated value should read it from a dedicated field instead
* (e.g. the per-match `label` for node titles).
*/
-export interface CanvasSearchMatch {
+export const canvasSearchSourceSchema = z.object({
+ /** SHA-256 of the exact source segment, not its rendered text. */
+ hash: z.string().regex(/^[a-f0-9]{64}$/),
+ /** UTF-16 offset within that source segment. */
+ offset: z.number().int().nonnegative(),
+ /** Original-source length when Unicode case folding changes UTF-16 length. */
+ length: z.number().int().positive().optional(),
+});
+export type CanvasSearchSource = z.infer;
+
+export const canvasSearchMatchSchema = z.object({
/**
* What kind of canvas entity this match belongs to.
*
@@ -121,21 +131,21 @@ export interface CanvasSearchMatch {
* live in `sourceNodeId` / `targetNodeId` so the client can
* `fitView` on both ends of the edge.
*/
- kind?: 'node' | 'edge';
- nodeId: string;
- nodeType: string;
+ kind: z.enum(['node', 'edge']).optional(),
+ nodeId: z.string(),
+ nodeType: z.string(),
/** The node's display label at scan time (handy for result rows). */
- label: string | null;
- field: SearchField;
- snippet: string;
+ label: z.string().nullable(),
+ field: searchFieldSchema,
+ snippet: z.string(),
/** Match start offset within `snippet` (not within the original field). */
- matchStart: number;
- matchLength: number;
+ matchStart: z.number().int().nonnegative(),
+ matchLength: z.number().int().positive(),
/**
* 0-based ordinal of this hit among all hits in the same
* `(nodeId, field)`. Server-stamped at emission time so the client
- * can address the n-th occurrence inside a preview without having
- * to count `matchStart` values itself — which goes wrong as soon
+ * can order results without counting snippet-relative `matchStart`
+ * values itself — which goes wrong as soon
* as results truncate (global `limit` hits before this node's
* earlier hits ship) or stream out of order (a later-stream-arriving
* hit with a smaller `matchStart` would otherwise retroactively
@@ -145,12 +155,14 @@ export interface CanvasSearchMatch {
* for this `(nodeId, field)` pair — i.e. it survives truncation and
* is monotonically increasing per (nodeId, field).
*/
- occurrenceIndex: number;
+ occurrenceIndex: z.number().int().nonnegative(),
+ source: canvasSearchSourceSchema.optional(),
/** Edge-only — source endpoint node id. Set iff `kind === 'edge'`. */
- sourceNodeId?: string;
+ sourceNodeId: z.string().optional(),
/** Edge-only — target endpoint node id. Set iff `kind === 'edge'`. */
- targetNodeId?: string;
-}
+ targetNodeId: z.string().optional(),
+});
+export type CanvasSearchMatch = z.infer;
/** NDJSON frame types emitted by the server. */
export type CanvasSearchEvent =
diff --git a/packages/shared/src/types/api/space-operations.test.ts b/packages/shared/src/types/api/space-operations.test.ts
index f976e99ef..31ecc6827 100644
--- a/packages/shared/src/types/api/space-operations.test.ts
+++ b/packages/shared/src/types/api/space-operations.test.ts
@@ -17,6 +17,47 @@ import {
CANVAS_NODE_TYPES,
} from '../canvas/index.js';
+it('shares Canvas source addresses with strict Space SEARCH responses', () => {
+ const match = {
+ nodeId: 'note',
+ nodeType: 'note',
+ label: 'Note',
+ field: 'content',
+ snippet: 'needle',
+ matchStart: 0,
+ matchLength: 6,
+ occurrenceIndex: 0,
+ source: { hash: 'a'.repeat(64), offset: 10, length: 6 },
+ };
+ const response = (value: object) => ({
+ type: 'SEARCH',
+ result: {
+ count: 1,
+ truncated: false,
+ matches: [{ tier: 'content', match: value }],
+ },
+ });
+ expect(spaceQueryResponseSchema.parse(response(match))).toEqual(
+ response(match),
+ );
+ expect(
+ spaceQueryResponseSchema.safeParse(
+ response({
+ ...match,
+ source: { ...match.source, offset: -1 },
+ }),
+ ).success,
+ ).toBe(false);
+ expect(
+ spaceQueryResponseSchema.safeParse(
+ response({
+ ...match,
+ unexpected: true,
+ }),
+ ).success,
+ ).toBe(false);
+});
+
describe('agentCanvasCommandSchema', () => {
it('rejects the retired Pin command at both agent boundaries', () => {
const command = {
diff --git a/packages/shared/src/types/api/space-operations.ts b/packages/shared/src/types/api/space-operations.ts
index f1cdcecfe..1f8497a26 100644
--- a/packages/shared/src/types/api/space-operations.ts
+++ b/packages/shared/src/types/api/space-operations.ts
@@ -11,7 +11,10 @@
import { z } from 'zod';
-import { canvasSearchRequestSchema } from './canvas-search.js';
+import {
+ canvasSearchMatchSchema,
+ canvasSearchRequestSchema,
+} from './canvas-search.js';
import {
ACCENT_PALETTE,
AGENT_CANVAS_COMMAND_TYPES,
@@ -852,21 +855,7 @@ const inspectEdgesResultSchema = z
})
.strict();
-const searchMatchSchema = z
- .object({
- kind: z.enum(['node', 'edge']).optional(),
- nodeId: z.string(),
- nodeType: z.string(),
- label: z.string().nullable(),
- field: z.enum(['label', 'summary', 'keywords', 'content', 'conversation']),
- snippet: z.string(),
- matchStart: z.number().int().nonnegative(),
- matchLength: z.number().int().nonnegative(),
- occurrenceIndex: z.number().int().nonnegative(),
- sourceNodeId: z.string().optional(),
- targetNodeId: z.string().optional(),
- })
- .strict();
+const searchMatchSchema = canvasSearchMatchSchema.strict();
const searchResultSchema = z
.object({
diff --git a/packages/shared/src/utils/text-search.test.ts b/packages/shared/src/utils/text-search.test.ts
new file mode 100644
index 000000000..1dff903f5
--- /dev/null
+++ b/packages/shared/src/utils/text-search.test.ts
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+import { describe, expect, it } from 'vitest';
+
+import { literalTextMatches } from './text-search.js';
+
+describe('literalTextMatches', () => {
+ it.each([
+ [
+ 'Needle NEEDLE',
+ 'needle',
+ [
+ { start: 0, end: 6 },
+ { start: 7, end: 13 },
+ ],
+ ],
+ [
+ 'aaaa',
+ 'aa',
+ [
+ { start: 0, end: 2 },
+ { start: 2, end: 4 },
+ ],
+ ],
+ ['\u0130 needle', 'needle', [{ start: 2, end: 8 }]],
+ ['\u0130 needle', 'i\u0307', [{ start: 0, end: 1 }]],
+ ['\u0130', 'i', []],
+ ['\u0130', '\u0307', []],
+ ['\u0130\u0130i', 'i\u0307i', [{ start: 1, end: 3 }]],
+ [
+ '\u0130\u0130i \u0130\u0130i',
+ 'i\u0307i',
+ [
+ { start: 1, end: 3 },
+ { start: 5, end: 7 },
+ ],
+ ],
+ ['ΟΣ', 'ς', [{ start: 1, end: 2 }]],
+ ['\u0130 ΟΣ', 'ς', [{ start: 3, end: 4 }]],
+ ['ΟΣ!', 'ς', [{ start: 1, end: 2 }]],
+ ['\ud83d\ude00 needle', 'needle', [{ start: 3, end: 9 }]],
+ ['text', '', []],
+ ] as const)('keeps source offsets for %j / %j', (text, query, expected) => {
+ expect([...literalTextMatches(text, query)]).toEqual(expected);
+ });
+});
diff --git a/packages/shared/src/utils/text-search.ts b/packages/shared/src/utils/text-search.ts
new file mode 100644
index 000000000..449530ba0
--- /dev/null
+++ b/packages/shared/src/utils/text-search.ts
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+/** Literal, non-overlapping case-insensitive matches in original UTF-16 offsets. */
+export function* literalTextMatches(
+ text: string,
+ query: string,
+): Generator<{ start: number; end: number }> {
+ const needle = query.toLowerCase();
+ if (!needle) return;
+ const lower = text.toLowerCase();
+ let offsets: number[] | undefined;
+ if (lower.length !== text.length) {
+ offsets = [];
+ let sourceOffset = 0;
+ for (const character of text) {
+ for (let index = 0; index < character.toLowerCase().length; index++)
+ offsets.push(sourceOffset);
+ sourceOffset += character.length;
+ }
+ offsets.push(text.length);
+ }
+ let from = 0;
+ while (true) {
+ const index = lower.indexOf(needle, from);
+ if (index < 0) return;
+ const start = offsets?.[index] ?? index;
+ const end = offsets?.[index + needle.length] ?? index + needle.length;
+ const accepted =
+ !offsets ||
+ ((index === 0 || offsets[index - 1] !== start) &&
+ offsets[index + needle.length - 1] !== end);
+ if (accepted) yield { start, end };
+ from = index + (accepted ? needle.length : 1);
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5008f7278..bd647488b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -386,6 +386,9 @@ importers:
'@milkdown/utils':
specifier: ^7.21.1
version: 7.21.2
+ '@noble/hashes':
+ specifier: ^1.8.0
+ version: 1.8.0
'@xyflow/react':
specifier: ^12.10.0
version: 12.11.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)