Skip to content
Open
43 changes: 43 additions & 0 deletions apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";

vi.mock("../hooks/useTheme", () => ({
useTheme: () => ({
theme: "light",
resolvedTheme: "light",
}),
}));

describe("ChatMarkdown", () => {
it("highlights assistant markdown text matches", async () => {
const { default: ChatMarkdown } = await import("./ChatMarkdown");
const markup = renderToStaticMarkup(
<ChatMarkdown
text="The **highlight** should appear inside assistant markdown."
cwd={undefined}
searchQuery="highlight"
searchActive
/>,
);

expect(markup).toContain('data-thread-search-highlight="active"');
expect(markup).toContain("<mark");
expect(markup).toContain(">highlight<");
});

it("highlights fenced code matches without dropping the visible mark", async () => {
const { default: ChatMarkdown } = await import("./ChatMarkdown");
const markup = renderToStaticMarkup(
<ChatMarkdown
text={"```ts\nconst highlightNeedle = true;\n```"}
cwd={undefined}
searchQuery="highlightNeedle"
searchActive
/>,
);

expect(markup).toContain('data-thread-search-highlight="active"');
expect(markup).toContain("<mark");
expect(markup).toContain(">highlightNeedle<");
});
});
46 changes: 43 additions & 3 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ import { LRUCache } from "../lib/lruCache";
import { useTheme } from "../hooks/useTheme";
import { resolveMarkdownFileLinkTarget } from "../markdown-links";
import { readNativeApi } from "../nativeApi";
import {
createThreadSearchHighlightRehypePlugin,
renderHighlightedText,
textContainsThreadSearchMatch,
} from "./chat/threadSearchHighlight";

class CodeHighlightErrorBoundary extends React.Component<
{ fallback: ReactNode; children: ReactNode },
Expand All @@ -49,6 +54,8 @@ interface ChatMarkdownProps {
text: string;
cwd: string | undefined;
isStreaming?: boolean;
searchQuery?: string;
searchActive?: boolean;
}

const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
Expand Down Expand Up @@ -235,9 +242,19 @@ function SuspenseShikiCodeBlock({
);
}

function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
function ChatMarkdown({
text,
cwd,
isStreaming = false,
searchQuery = "",
searchActive = false,
}: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const searchHighlightPlugin = useMemo(
() => createThreadSearchHighlightRehypePlugin(searchQuery, { active: searchActive }),
[searchActive, searchQuery],
);
const markdownComponents = useMemo<Components>(
() => ({
a({ node: _node, href, ...props }) {
Expand Down Expand Up @@ -269,6 +286,25 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
return <pre {...props}>{children}</pre>;
}

if (textContainsThreadSearchMatch(codeBlock.code, searchQuery)) {
return (
<MarkdownCodeBlock code={codeBlock.code}>
<pre {...props}>
<code className={codeBlock.className}>
{renderHighlightedText(
codeBlock.code,
searchQuery,
`markdown-code:${codeBlock.code}`,
{
active: searchActive,
},
)}
</code>
</pre>
</MarkdownCodeBlock>
);
}

return (
<MarkdownCodeBlock code={codeBlock.code}>
<CodeHighlightErrorBoundary fallback={<pre {...props}>{children}</pre>}>
Expand All @@ -285,12 +321,16 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
);
},
}),
[cwd, diffThemeName, isStreaming],
[cwd, diffThemeName, isStreaming, searchActive, searchQuery],
);

return (
<div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={searchHighlightPlugin ? [searchHighlightPlugin] : []}
components={markdownComponents}
>
{text}
</ReactMarkdown>
</div>
Expand Down
Loading
Loading