diff --git a/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts b/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts index 6dcc9791a0d..4bdf5d9c358 100644 --- a/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/conversationSearch.pure.test.ts @@ -53,6 +53,7 @@ describe('conversationSearch.pure', () => { createdAt: contentSession.updatedAt, snippet: null, preview: 'billing search details', + occurrenceCount: 1, score: 99, ftsRank: null, vectorRank: 1, @@ -77,6 +78,7 @@ describe('conversationSearch.pure', () => { createdAt: newerContentSession.updatedAt, snippet: null, preview: 'billing search details', + occurrenceCount: 1, score: 99, ftsRank: null, vectorRank: 1, @@ -102,6 +104,7 @@ describe('conversationSearch.pure', () => { createdAt: contentSession.updatedAt, snippet: null, preview: 'billing search details', + occurrenceCount: 1, score: 99, ftsRank: null, vectorRank: 1, @@ -126,6 +129,7 @@ describe('conversationSearch.pure', () => { createdAt: target.updatedAt, snippet: 'search settings', preview: 'search settings preview', + occurrenceCount: 1, score: 1, ftsRank: 1, vectorRank: null, @@ -153,6 +157,7 @@ describe('conversationSearch.pure', () => { createdAt: '2026-01-01T00:00:00.000Z', snippet: 'older search settings', preview: 'older search settings preview', + occurrenceCount: 1, score: 1, ftsRank: 2, vectorRank: null, @@ -167,6 +172,7 @@ describe('conversationSearch.pure', () => { createdAt: '2026-01-02T00:00:00.000Z', snippet: 'newer search settings', preview: 'newer search settings preview', + occurrenceCount: 1, score: 9, ftsRank: 1, vectorRank: null, @@ -265,14 +271,111 @@ describe('conversationSearch.pure', () => { }); }); - it('extracts visible AskUser and plan review text for conversation search', () => { + it('matches and counts the complete query phrase', () => { + expect( + normalizeConversationContentPreview( + 'assistant', + 'error then timeout; error timeout and ERROR TIMEOUT', + 'error timeout', + ), + ).toMatchObject({ + keywordMatchedVisibleText: true, + occurrenceCount: 2, + }); + expect( + normalizeConversationContentPreview( + 'assistant', + 'error happened before a later timeout', + 'error timeout', + ), + ).toMatchObject({ + keywordMatchedVisibleText: false, + occurrenceCount: 0, + }); + }); + + it('keeps visible code text while excluding Markdown source details', () => { + const preview = normalizeConversationContentPreview( + 'assistant', + '`
- {feedback || t('chat.planReviewBubble.noFeedback')} -
++ {feedback || t('chat.planReviewBubble.noFeedback')} +
+error
timeout
'; + expect(findSessionSearchRanges(root, 'error timeout')).toHaveLength(1); + const inline = document.createElement('div'); inline.innerHTML = 'GPT'; + expect(findSessionSearchRanges(inline, 'GPT')).toHaveLength(1); + }); + it('skips interactive and aria-hidden text', () => { + const root = document.createElement('div'); root.innerHTML = 'GPT
'; + expect(findSessionSearchRanges(root, 'GPT')).toHaveLength(1); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts b/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts new file mode 100644 index 00000000000..e332f1ca315 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/sessionSearchHighlight.ts @@ -0,0 +1,95 @@ +const BLOCK_TAGS = new Set([ + 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'DIV', 'DL', 'FIELDSET', 'FIGURE', + 'FOOTER', 'FORM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 'HR', 'LI', + 'MAIN', 'NAV', 'OL', 'P', 'PRE', 'SECTION', 'TABLE', 'UL', +]); +const SKIPPED_TEXT_ANCESTOR = + 'button,input,textarea,select,script,style,[aria-hidden="true"],[data-session-search-ignore]'; + +interface TextSegment { node: Text; start: number; end: number } +interface NormalizedSearchText { text: string; starts: number[]; ends: number[] } + +function normalizeSearchText(source: string): NormalizedSearchText { + const lower = source.toLocaleLowerCase(); + let text = ''; + const starts: number[] = []; + const ends: number[] = []; + let whitespaceStart = -1; + for (let index = 0; index < lower.length; index += 1) { + if (/\s/.test(lower[index])) { + if (text && whitespaceStart < 0) whitespaceStart = index; + continue; + } + if (whitespaceStart >= 0) { + text += ' '; + starts.push(whitespaceStart); + ends.push(index); + whitespaceStart = -1; + } + text += lower[index]; + starts.push(index); + ends.push(index + 1); + } + return { text, starts, ends }; +} + +function blockAncestor(node: Node): Element | null { + let current: Element | null = node.parentElement; + while (current) { + if (BLOCK_TAGS.has(current.tagName)) return current; + current = current.parentElement; + } + return null; +} + +/** Build DOM ranges for visible, non-interactive text matches without changing React's DOM tree. */ +export function findSessionSearchRanges(root: Element, query: string): Range[] { + const normalizedQuery = query.replace(/\s+/g, ' ').trim().toLocaleLowerCase(); + if (!normalizedQuery) return []; + const segments: TextSegment[] = []; + let text = ''; + let previousBlock: Element | null = null; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const textNode = node as Text; + const parent = textNode.parentElement; + const value = textNode.data; + if ( + !parent || + !value || + parent.closest(SKIPPED_TEXT_ANCESTOR) + ) continue; + const block = blockAncestor(textNode); + if (block && previousBlock && block !== previousBlock && text.length > 0 && !/\s$/.test(text)) { + text += ' '; + } + const start = text.length; + text += value; + segments.push({ node: textNode, start, end: text.length }); + previousBlock = block; + } + const normalized = normalizeSearchText(text); + const ranges: Range[] = []; + let startSegmentIndex = 0; + let endSegmentIndex = 0; + let offset = normalized.text.indexOf(normalizedQuery); + while (offset >= 0) { + const normalizedEnd = offset + normalizedQuery.length; + const sourceStart = normalized.starts[offset]; + const sourceEnd = normalized.ends[normalizedEnd - 1]; + if (sourceStart === undefined || sourceEnd === undefined) break; + while (segments[startSegmentIndex]?.end <= sourceStart) startSegmentIndex += 1; + endSegmentIndex = Math.max(endSegmentIndex, startSegmentIndex); + while (segments[endSegmentIndex]?.end < sourceEnd) endSegmentIndex += 1; + const startSegment = segments[startSegmentIndex]; + const endSegment = segments[endSegmentIndex]; + if (startSegment && endSegment && startSegment.start <= sourceStart && sourceStart < startSegment.end && endSegment.start < sourceEnd && sourceEnd <= endSegment.end) { + const range = document.createRange(); + range.setStart(startSegment.node, sourceStart - startSegment.start); + range.setEnd(endSegment.node, sourceEnd - endSegment.start); + ranges.push(range); + } + offset = normalized.text.indexOf(normalizedQuery, normalizedEnd); + } + return ranges; +} diff --git a/apps/desktop/src/renderer/components/find-in-page/FindInPageBar.tsx b/apps/desktop/src/renderer/components/find-in-page/FindInPageBar.tsx index b28e36507f2..79e71ace3bd 100644 --- a/apps/desktop/src/renderer/components/find-in-page/FindInPageBar.tsx +++ b/apps/desktop/src/renderer/components/find-in-page/FindInPageBar.tsx @@ -4,7 +4,7 @@ import { ChevronDown, ChevronUp, X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAppShortcut } from '@/hooks/useAppShortcut'; -import { isFindInPageClaimed } from './findInPageOwnership'; +import { isFindInPageClaimed, subscribeFindInPageClaim } from './findInPageOwnership'; /** * F-FIP-1 — Find in Page overlay (Ctrl/Cmd+F). @@ -57,6 +57,12 @@ export function FindInPageBar() { window.electronAPI.stopFindInPage('clearSelection'); }, []); + useEffect(() => { + return subscribeFindInPageClaim(() => { + if (isFindInPageClaimed()) close(); + }); + }, [close]); + // Global find-in-page shortcut (registry 默认 Ctrl/Cmd+F, 用户可改绑) → // open + focus. Capture phase so editable inputs (TipTap, plain inputs, // contenteditable) don't swallow the chord first. @@ -96,7 +102,7 @@ export function FindInPageBar() { [], ); - if (!open) return null; + if (!open || isFindInPageClaimed()) return null; return (