1function foldAsciiCase(value: string): string {
2 return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
3}
4
5function HighlightedSearchText(props: { text: string; query: string }) {
6 const query = props.query.trim();
7 if (query.length === 0) return props.text;
8
9 const normalizedText = foldAsciiCase(props.text);
10 const normalizedQuery = foldAsciiCase(query);
11 const parts: Array<{
12 readonly text: string;
13 readonly highlighted: boolean;
14 readonly start: number;
15 }> = [];
16 let cursor = 0;
17
18 while (cursor < props.text.length) {
19 const matchIndex = normalizedText.indexOf(normalizedQuery, cursor);
20 if (matchIndex === -1) {
21 parts.push({ text: props.text.slice(cursor), highlighted: false, start: cursor });
22 break;
23 }
24 if (matchIndex > cursor) {
25 parts.push({
26 text: props.text.slice(cursor, matchIndex),
27 highlighted: false,
28 start: cursor,
29 });
30 }
31 parts.push({
32 text: props.text.slice(matchIndex, matchIndex + query.length),
33 highlighted: true,
34 start: matchIndex,
35 });
36 cursor = matchIndex + query.length;
37 }
38
39 return parts.map((part) =>
40 part.highlighted ? (
41 <mark className="bg-transparent font-semibold text-foreground" key={part.start}>
42 {part.text}
43 </mark>
44 ) : (
45 part.text
46 ),
47 );
48}
49
50export function ThreadSearchMatchExcerpt(props: {
51 match: {
52 readonly source: "user" | "assistant";
53 readonly snippet: string;
54 readonly query: string;
55 };
56}) {
57 const isUser = props.match.source === "user";
58 return (
59 <span className="truncate text-xs text-muted-foreground/85">
60 <span className={isUser ? "text-info-foreground" : "text-success-foreground"}>
61 {isUser ? "You:" : "Agent:"}
62 </span>{" "}
63 <HighlightedSearchText text={props.match.snippet} query={props.match.query} />
64 </span>
65 );
66}