diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx index 0e758a5dbc..1493370dcf 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx @@ -851,6 +851,7 @@ describe('ModernFlowChatContainer historical empty state', () => { virtualItemIndex: 0, turnId: 'turn-1', type: 'user-message', + occurrenceIndex: 0, }]; searchStateMock.currentMatchIndex = 0; searchStateMock.currentMatchVirtualIndex = 0; @@ -864,6 +865,7 @@ describe('ModernFlowChatContainer historical empty state', () => { virtualItemIndex: 0, query: 'search', flowItemId: undefined, + occurrenceIndex: 0, expandableIds: undefined, }); @@ -878,6 +880,7 @@ describe('ModernFlowChatContainer historical empty state', () => { virtualItemIndex: 0, query: 'searchable', flowItemId: undefined, + occurrenceIndex: 0, expandableIds: undefined, }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index e96a71c1a3..7d0a60d13d 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -334,6 +334,7 @@ export const ModernFlowChatContainer: React.FC = ( const searchCurrentMatchFlowItemId = searchCurrentMatch?.flowItemId; const searchCurrentMatchTurnId = searchCurrentMatch?.turnId; const searchCurrentMatchVirtualItemIndex = searchCurrentMatch?.virtualItemIndex ?? -1; + const searchCurrentMatchOccurrenceIndex = searchCurrentMatch?.occurrenceIndex ?? 0; const searchCurrentMatchExpandableKey = searchCurrentMatch?.expandableIds?.join('\u0000') ?? ''; useFlowChatSync(); @@ -1085,6 +1086,7 @@ export const ModernFlowChatContainer: React.FC = ( virtualItemIndex: searchCurrentMatchVirtualItemIndex, query: searchQuery, flowItemId: searchCurrentMatchFlowItemId, + occurrenceIndex: searchCurrentMatchOccurrenceIndex, expandableIds: searchCurrentMatchExpandableKey ? searchCurrentMatchExpandableKey.split('\u0000') : undefined, @@ -1098,6 +1100,7 @@ export const ModernFlowChatContainer: React.FC = ( searchCurrentMatchTurnId, searchCurrentMatchExpandableKey, searchCurrentMatchVirtualItemIndex, + searchCurrentMatchOccurrenceIndex, searchQuery, ]); diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss index 620e6b8bec..a1dd62f675 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss @@ -41,3 +41,8 @@ color: inherit; background: color-mix(in srgb, var(--color-accent-500) 42%, transparent); } + +::highlight(bitfun-flowchat-search-match) { + color: inherit; + background: color-mix(in srgb, var(--color-accent-500) 16%, transparent); +} diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index 196f75e53e..ba1389bd98 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -55,7 +55,7 @@ import { } from './historyProjectionHandoff'; import { findElementWithDataValue, - findFlowChatSearchTextRange, + findFlowChatSearchTextRanges, getFlowChatSearchTextRoot, setFlowChatSearchHighlight, } from './flowChatSearchDom'; @@ -110,6 +110,7 @@ export interface VirtualMessageListRef { virtualItemIndex: number; query: string; flowItemId?: string; + occurrenceIndex?: number; expandableIds?: readonly string[]; }) => void; clearSearchMatch: () => void; @@ -3606,6 +3607,7 @@ const VirtualMessageListSession = forwardRef { const query = target.query.trim(); @@ -3735,15 +3737,19 @@ const VirtualMessageListSession = forwardRef index !== rangeIndex)); let ancestor = range.startContainer.parentElement; while (ancestor && ancestor !== scroller && wrapper.contains(ancestor)) { diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.test.ts b/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.test.ts index f49cb08a00..3dd1e1eb23 100644 --- a/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { findElementWithDataValue, findFlowChatSearchTextRange, + findFlowChatSearchTextRanges, getFlowChatSearchTextRoot, } from './flowChatSearchDom'; @@ -43,6 +44,23 @@ describe('FlowChat search DOM navigation', () => { expect(getFlowChatSearchTextRoot(wrapper, 'item"with-special')).toBe(target); }); + it('finds every occurrence in document order', () => { + const root = document.createElement('div'); + root.innerHTML = '

needle first

then needle second and needle third

'; + + const ranges = findFlowChatSearchTextRanges(root, 'needle'); + + expect(ranges).toHaveLength(3); + expect(ranges.map(range => range.toString())).toEqual(['needle', 'needle', 'needle']); + }); + + it('finds non-overlapping occurrences only', () => { + const root = document.createElement('div'); + root.textContent = 'aaa'; + + expect(findFlowChatSearchTextRanges(root, 'aa')).toHaveLength(1); + }); + it('ignores text hidden by a collapsed accessible container', () => { const root = document.createElement('div'); root.innerHTML = '
visible needle
'; diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts b/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts index d9ab18d9d7..d38b7b93f2 100644 --- a/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts +++ b/src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts @@ -1,4 +1,5 @@ -const SEARCH_HIGHLIGHT_NAME = 'bitfun-flowchat-search-current'; +const SEARCH_HIGHLIGHT_CURRENT_NAME = 'bitfun-flowchat-search-current'; +const SEARCH_HIGHLIGHT_MATCH_NAME = 'bitfun-flowchat-search-match'; type HighlightRegistryLike = { set: (name: string, highlight: unknown) => void; @@ -61,13 +62,14 @@ function foldTextWithOriginalOffsets(text: string): { } /** - * Finds a case-insensitive query even when Markdown splits it across adjacent - * text nodes (for example, around inline emphasis or code spans). + * Finds every non-overlapping case-insensitive occurrence of the query, even + * when Markdown splits it across adjacent text nodes (for example, around + * inline emphasis or code spans). Ranges are returned in document order. */ -export function findFlowChatSearchTextRange(root: HTMLElement, query: string): Range | null { +export function findFlowChatSearchTextRanges(root: HTMLElement, query: string): Range[] { const trimmedQuery = query.trim(); if (!trimmedQuery) { - return null; + return []; } const ownerDocument = root.ownerDocument; @@ -91,31 +93,48 @@ export function findFlowChatSearchTextRange(root: HTMLElement, query: string): R const folded = foldTextWithOriginalOffsets(combinedText); const foldedQuery = trimmedQuery.toLowerCase(); - const foldedMatchStart = folded.text.indexOf(foldedQuery); - if (foldedMatchStart < 0) { - return null; - } + const ranges: Range[] = []; + let searchFrom = 0; - const foldedMatchEnd = foldedMatchStart + foldedQuery.length; - const matchStart = folded.offsets[foldedMatchStart]?.start; - const matchEnd = folded.offsets[foldedMatchEnd - 1]?.end; - if (matchStart === undefined || matchEnd === undefined) { - return null; - } + for (;;) { + const foldedMatchStart = folded.text.indexOf(foldedQuery, searchFrom); + if (foldedMatchStart < 0) { + return ranges; + } + searchFrom = foldedMatchStart + foldedQuery.length; - const startEntry = textNodes.find(entry => matchStart >= entry.start && matchStart < entry.end); - const endEntry = textNodes.find(entry => matchEnd > entry.start && matchEnd <= entry.end); - if (!startEntry || !endEntry) { - return null; + const foldedMatchEnd = foldedMatchStart + foldedQuery.length; + const matchStart = folded.offsets[foldedMatchStart]?.start; + const matchEnd = folded.offsets[foldedMatchEnd - 1]?.end; + if (matchStart === undefined || matchEnd === undefined) { + continue; + } + + const startEntry = textNodes.find(entry => matchStart >= entry.start && matchStart < entry.end); + const endEntry = textNodes.find(entry => matchEnd > entry.start && matchEnd <= entry.end); + if (!startEntry || !endEntry) { + continue; + } + + const range = ownerDocument.createRange(); + range.setStart(startEntry.node, matchStart - startEntry.start); + range.setEnd(endEntry.node, matchEnd - endEntry.start); + ranges.push(range); } +} - const range = ownerDocument.createRange(); - range.setStart(startEntry.node, matchStart - startEntry.start); - range.setEnd(endEntry.node, matchEnd - endEntry.start); - return range; +export function findFlowChatSearchTextRange(root: HTMLElement, query: string): Range | null { + return findFlowChatSearchTextRanges(root, query)[0] ?? null; } -export function setFlowChatSearchHighlight(range: Range | null): void { +/** + * Highlights the current occurrence and, more faintly, every other occurrence + * in the same text root. Passing `null` clears both highlight registries. + */ +export function setFlowChatSearchHighlight( + currentRange: Range | null, + otherRanges: readonly Range[] = [], +): void { const cssWithHighlights = globalThis.CSS as (typeof CSS & { highlights?: HighlightRegistryLike; }) | undefined; @@ -127,11 +146,21 @@ export function setFlowChatSearchHighlight(range: Range | null): void { return; } - cssWithHighlights.highlights.delete(SEARCH_HIGHLIGHT_NAME); - if (range && HighlightConstructor) { + cssWithHighlights.highlights.delete(SEARCH_HIGHLIGHT_CURRENT_NAME); + cssWithHighlights.highlights.delete(SEARCH_HIGHLIGHT_MATCH_NAME); + if (!HighlightConstructor) { + return; + } + if (currentRange) { + cssWithHighlights.highlights.set( + SEARCH_HIGHLIGHT_CURRENT_NAME, + new HighlightConstructor(currentRange), + ); + } + if (otherRanges.length > 0) { cssWithHighlights.highlights.set( - SEARCH_HIGHLIGHT_NAME, - new HighlightConstructor(range), + SEARCH_HIGHLIGHT_MATCH_NAME, + new HighlightConstructor(...otherRanges), ); } } diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.test.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.test.ts index 8781e11db9..65faf9160e 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.test.ts @@ -55,10 +55,42 @@ describe('buildFlowChatSearchMatches', () => { turnId: 'turn-1', type: 'model-round', flowItemId: 'text-2', + occurrenceIndex: 0, expandableIds: undefined, }]); }); + it('reports every occurrence within a single source', () => { + const virtualItems = [{ + type: 'model-round', + turnId: 'turn-1', + isLastRound: true, + isTurnComplete: true, + data: { + id: 'round-1', + items: [ + { id: 'text-1', type: 'text', content: 'needle one, needle two, needle three' }, + ], + }, + }] as VirtualItem[]; + + expect(buildFlowChatSearchMatches(virtualItems, 'needle')).toEqual([ + expect.objectContaining({ flowItemId: 'text-1', occurrenceIndex: 0 }), + expect.objectContaining({ flowItemId: 'text-1', occurrenceIndex: 1 }), + expect.objectContaining({ flowItemId: 'text-1', occurrenceIndex: 2 }), + ]); + }); + + it('counts non-overlapping occurrences only', () => { + const virtualItems = [{ + type: 'user-message', + turnId: 'turn-1', + data: { id: 'user-1', content: 'aaa' }, + }] as VirtualItem[]; + + expect(buildFlowChatSearchMatches(virtualItems, 'aa')).toHaveLength(1); + }); + it('records collapsed containers from outermost to innermost', () => { const virtualItems = [{ type: 'explore-group', @@ -79,7 +111,7 @@ describe('buildFlowChatSearchMatches', () => { }); }); - it('deduplicates by turn while searching steering messages', () => { + it('keeps separate matches for each item in the same turn', () => { const virtualItems = [ { type: 'user-steering-message', @@ -100,11 +132,10 @@ describe('buildFlowChatSearchMatches', () => { }, ] as VirtualItem[]; - expect(buildFlowChatSearchMatches(virtualItems, 'needle')).toHaveLength(1); - expect(buildFlowChatSearchMatches(virtualItems, 'needle')[0]).toMatchObject({ - virtualItemIndex: 0, - type: 'user-steering-message', - }); + expect(buildFlowChatSearchMatches(virtualItems, 'needle')).toEqual([ + expect.objectContaining({ virtualItemIndex: 0, type: 'user-steering-message', occurrenceIndex: 0 }), + expect.objectContaining({ virtualItemIndex: 1, type: 'model-round', flowItemId: 'text-1', occurrenceIndex: 0 }), + ]); }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.ts index ec2f6b4471..a678d1dccf 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.ts @@ -1,6 +1,7 @@ /** * FlowChat message search hook. - * Searches user + model text, deduplicated by dialog turn: one match per turn. + * Searches user + model text and reports every occurrence of the query, so a + * turn containing several hits contributes several navigable matches. * Each match also keeps the concrete rendered source so navigation can land on * the matching text instead of only centering a potentially very tall turn. */ @@ -15,12 +16,14 @@ interface SearchableFlowItem { } export interface SearchMatch { - /** Smallest virtual index in this turn where text matched. */ + /** Virtual index of the item containing this occurrence. */ virtualItemIndex: number; turnId: string; type: VirtualItem['type']; - /** Rendered FlowItem containing the first match in this turn, when applicable. */ + /** Rendered FlowItem containing this occurrence, when applicable. */ flowItemId?: string; + /** Zero-based occurrence of the query within this source's text. */ + occurrenceIndex: number; /** Collapsible containers that must be opened, from outermost to innermost. */ expandableIds?: readonly string[]; } @@ -92,6 +95,20 @@ function getVirtualItemSearchSources(item: VirtualItem): SearchableSource[] { return []; } +function countQueryOccurrences(content: string, foldedQuery: string): number { + const haystack = content.toLowerCase(); + let count = 0; + let from = 0; + for (;;) { + const index = haystack.indexOf(foldedQuery, from); + if (index < 0) { + return count; + } + count += 1; + from = index + foldedQuery.length; + } +} + export function buildFlowChatSearchMatches( virtualItems: readonly VirtualItem[], searchQuery: string, @@ -99,31 +116,25 @@ export function buildFlowChatSearchMatches( const trimmed = searchQuery.trim(); if (!trimmed) return []; const query = trimmed.toLowerCase(); - const firstMatchByTurn = new Map(); + const matches: SearchMatch[] = []; virtualItems.forEach((item, virtualItemIndex) => { - if (firstMatchByTurn.has(item.turnId)) { - return; - } - - const source = getVirtualItemSearchSources(item).find(candidate => ( - candidate.content.toLowerCase().includes(query) - )); - if (!source) { - return; + for (const source of getVirtualItemSearchSources(item)) { + const occurrenceCount = countQueryOccurrences(source.content, query); + for (let occurrenceIndex = 0; occurrenceIndex < occurrenceCount; occurrenceIndex += 1) { + matches.push({ + virtualItemIndex, + turnId: item.turnId, + type: item.type, + flowItemId: source.flowItemId, + occurrenceIndex, + expandableIds: source.expandableIds, + }); + } } - - firstMatchByTurn.set(item.turnId, { - virtualItemIndex, - turnId: item.turnId, - type: item.type, - flowItemId: source.flowItemId, - expandableIds: source.expandableIds, - }); }); - return [...firstMatchByTurn.values()] - .sort((left, right) => left.virtualItemIndex - right.virtualItemIndex); + return matches; } export function useFlowChatSearch(virtualItems: VirtualItem[]): UseFlowChatSearchReturn {