Skip to content
Draft
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
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,17 @@
"jszip": "3.10.1",
"lexical": "^0.41.0",
"lucide-react": "^0.564.0",
"mdast-util-to-string": "^4.0.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"tailwind-merge": "^3.4.0",
"unified": "^11.0.5",
"zustand": "^5.0.11"
},
"devDependencies": {
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,56 @@ import {
computeStableMessagesTimelineRows,
computeMessageDurationStart,
deriveMessagesTimelineRows,
findTextRanges,
findTextMatches,
normalizeCompactToolLabel,
renderMarkdownSearchText,
resolveAssistantMessageCopyState,
shouldPreserveAssistantLineBreaks,
} from "./MessagesTimeline.logic";

describe("findTextMatches", () => {
it("finds every case-insensitive occurrence and identifies it within its text", () => {
expect(findTextMatches(["One one", null, "another ONE"], "one")).toEqual([
{ textIndex: 0, occurrenceIndex: 0 },
{ textIndex: 0, occurrenceIndex: 1 },
{ textIndex: 2, occurrenceIndex: 0 },
]);
expect(findTextMatches(["anything"], "")).toEqual([]);
});

it("uses original string offsets when case folding expands a Unicode character", () => {
expect(findTextRanges("İstanbul", "İ")).toEqual([{ start: 0, end: 1 }]);
});

it("optionally matches exact casing", () => {
expect(findTextMatches(["One one ONE"], "One", true)).toEqual([
{ textIndex: 0, occurrenceIndex: 0 },
]);
expect(findTextMatches(["One one ONE"], "one", true)).toEqual([
{ textIndex: 0, occurrenceIndex: 0 },
]);
});

it("searches rendered Markdown text rather than hidden link destinations", () => {
const text = renderMarkdownSearchText("Read [the docs](https://example.com/hidden) now");
expect(findTextMatches([text], "docs")).toHaveLength(1);
expect(findTextMatches([text], "hidden")).toHaveLength(0);
});

it("preserves matches that span adjacent inline Markdown nodes", () => {
const text = renderMarkdownSearchText("**foo**bar");
expect(text).toBe("foobar");
expect(findTextMatches([text], "oob")).toHaveLength(1);
});

it("searches inline and fenced code in prompts and responses", () => {
const text = renderMarkdownSearchText("Run `pnpm test`:\n\n```ts\nconst answer = 42;\n```");
expect(findTextMatches([text], "pnpm test")).toHaveLength(1);
expect(findTextMatches([text], "answer = 42")).toHaveLength(1);
});
});

describe("shouldPreserveAssistantLineBreaks", () => {
it("preserves Claude insight formatting without changing regular markdown", () => {
expect(
Expand Down
75 changes: 75 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import * as Equal from "effect/Equal";
import { toString } from "mdast-util-to-string";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import { unified } from "unified";
import {
formatDuration,
workEntryIndicatesToolNeutralStatus,
Expand Down Expand Up @@ -242,6 +246,77 @@ export function normalizeCompactToolLabel(value: string): string {
return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
}

export interface TextMatch {
readonly textIndex: number;
readonly occurrenceIndex: number;
}

export interface TextRange {
readonly start: number;
readonly end: number;
}

const markdownSearchTextProcessor = unified().use(remarkParse).use(remarkGfm);

export function renderMarkdownSearchText(markdown: string): string {
return toString(markdownSearchTextProcessor.parse(markdown), { includeImageAlt: false });
Comment thread
bj97301 marked this conversation as resolved.
}

export function findTextRanges(text: string, query: string, caseSensitive = false): TextRange[] {
if (caseSensitive) {
if (!query) return [];
const ranges: TextRange[] = [];
for (let offset = 0; (offset = text.indexOf(query, offset)) !== -1; offset += query.length) {
ranges.push({ start: offset, end: offset + query.length });
}
return ranges;
}

const normalizedOffsets: TextRange[] = [];
let normalizedText = "";
for (let start = 0; start < text.length; ) {
const codePoint = text.codePointAt(start)!;
const end = start + (codePoint > 0xffff ? 2 : 1);
const normalized = text.slice(start, end).toLowerCase();
normalizedText += normalized;
for (let index = 0; index < normalized.length; index += 1) {
normalizedOffsets.push({ start, end });
}
start = end;
}

const needle = query.toLowerCase();
if (!needle) return [];

const ranges: TextRange[] = [];
for (
let offset = 0;
(offset = normalizedText.indexOf(needle, offset)) !== -1;
offset += needle.length
) {
const first = normalizedOffsets[offset];
const last = normalizedOffsets[offset + needle.length - 1];
if (first && last) ranges.push({ start: first.start, end: last.end });
}
return ranges;
}

export function findTextMatches(
texts: ReadonlyArray<string | null | undefined>,
query: string,
caseSensitive = false,
): TextMatch[] {
if (!query) return [];

const matches: TextMatch[] = [];
texts.forEach((text, textIndex) => {
findTextRanges(text ?? "", query, caseSensitive).forEach((_, occurrenceIndex) => {
matches.push({ textIndex, occurrenceIndex });
});
});
return matches;
}

export function resolveAssistantMessageCopyState({
text,
showCopyButton,
Expand Down
Loading
Loading