Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -864,6 +865,7 @@ describe('ModernFlowChatContainer historical empty state', () => {
virtualItemIndex: 0,
query: 'search',
flowItemId: undefined,
occurrenceIndex: 0,
expandableIds: undefined,
});

Expand All @@ -878,6 +880,7 @@ describe('ModernFlowChatContainer historical empty state', () => {
virtualItemIndex: 0,
query: 'searchable',
flowItemId: undefined,
occurrenceIndex: 0,
expandableIds: undefined,
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
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();
Expand Down Expand Up @@ -1085,6 +1086,7 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
virtualItemIndex: searchCurrentMatchVirtualItemIndex,
query: searchQuery,
flowItemId: searchCurrentMatchFlowItemId,
occurrenceIndex: searchCurrentMatchOccurrenceIndex,
expandableIds: searchCurrentMatchExpandableKey
? searchCurrentMatchExpandableKey.split('\u0000')
: undefined,
Expand All @@ -1098,6 +1100,7 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
searchCurrentMatchTurnId,
searchCurrentMatchExpandableKey,
searchCurrentMatchVirtualItemIndex,
searchCurrentMatchOccurrenceIndex,
searchQuery,
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
14 changes: 10 additions & 4 deletions src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import {
} from './historyProjectionHandoff';
import {
findElementWithDataValue,
findFlowChatSearchTextRange,
findFlowChatSearchTextRanges,
getFlowChatSearchTextRoot,
setFlowChatSearchHighlight,
} from './flowChatSearchDom';
Expand Down Expand Up @@ -110,6 +110,7 @@ export interface VirtualMessageListRef {
virtualItemIndex: number;
query: string;
flowItemId?: string;
occurrenceIndex?: number;
expandableIds?: readonly string[];
}) => void;
clearSearchMatch: () => void;
Expand Down Expand Up @@ -3606,6 +3607,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
virtualItemIndex: number;
query: string;
flowItemId?: string;
occurrenceIndex?: number;
expandableIds?: readonly string[];
}) => {
const query = target.query.trim();
Expand Down Expand Up @@ -3735,15 +3737,19 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
return;
}

const range = findFlowChatSearchTextRange(textRoot, query);
if (!range) {
const ranges = findFlowChatSearchTextRanges(textRoot, query);
if (ranges.length === 0) {
if (attempts < SEARCH_NAVIGATION_MAX_ATTEMPTS) {
requestAnimationFrame(resolveExactTextPosition);
}
return;
}

setFlowChatSearchHighlight(range);
// Markdown syntax can make the raw-content occurrence count exceed the
// rendered one; clamping still lands navigation on a real occurrence.
const rangeIndex = Math.min(Math.max(target.occurrenceIndex ?? 0, 0), ranges.length - 1);
const range = ranges[rangeIndex];
setFlowChatSearchHighlight(range, ranges.filter((_, index) => index !== rangeIndex));

let ancestor = range.startContainer.parentElement;
while (ancestor && ancestor !== scroller && wrapper.contains(ancestor)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
import {
findElementWithDataValue,
findFlowChatSearchTextRange,
findFlowChatSearchTextRanges,
getFlowChatSearchTextRoot,
} from './flowChatSearchDom';

Expand Down Expand Up @@ -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 = '<p>needle first</p><p>then <em>nee</em>dle second and needle third</p>';

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 = '<div aria-hidden="true">hidden needle</div><div>visible needle</div>';
Expand Down
85 changes: 57 additions & 28 deletions src/web-ui/src/flow_chat/components/modern/flowChatSearchDom.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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),
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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 }),
]);
});
});

Expand Down
Loading