Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"decode-named-character-reference": "^1.3.0",
"effect": "catalog:",
"lexical": "^0.41.0",
"lucide-react": "^0.564.0",
Expand Down
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<");
});
});
126 changes: 39 additions & 87 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,15 @@ import { fnv1a32 } from "../lib/diffRendering";
import { LRUCache } from "../lib/lruCache";
import { useTheme } from "../hooks/useTheme";
import {
normalizeMarkdownLinkDestination,
createThreadSearchHighlightRehypePlugin,
renderHighlightedText,
textContainsThreadSearchMatch,
} from "./chat/threadSearchHighlight";
import {
buildFileLinkParentSuffixByPath,
buildMarkdownFileLinkLabel,
extractMarkdownLinkHrefs,
normalizeMarkdownLinkHrefKey,
resolveMarkdownFileLinkMeta,
rewriteMarkdownFileUriHref,
} from "../markdown-links";
Expand Down Expand Up @@ -62,6 +70,8 @@ interface ChatMarkdownProps {
cwd: string | undefined;
isStreaming?: boolean;
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
searchQuery?: string;
searchActive?: boolean;
}

const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
Expand Down Expand Up @@ -286,87 +296,11 @@ interface MarkdownFileLinkProps {
className?: string | undefined;
}

const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
const MARKDOWN_FILE_LINK_CLASS_NAME =
"chat-markdown-file-link relative top-[2px] max-w-full no-underline";
const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0";
const MARKDOWN_FILE_LINK_LABEL_CLASS_NAME = "chat-markdown-file-link-label truncate";

function pathParentSegments(path: string): string[] {
const normalized = path.replaceAll("\\", "/");
const segments = normalized.split("/").filter((segment) => segment.length > 0);
return segments.slice(0, -1);
}

function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<string, string> {
const groups = new Map<string, Set<string>>();
for (const filePath of filePaths) {
const pathSegments = filePath
.replaceAll("\\", "/")
.split("/")
.filter((segment) => segment.length > 0);
const basename = pathSegments[pathSegments.length - 1];
if (!basename) continue;
const group = groups.get(basename) ?? new Set<string>();
group.add(filePath);
groups.set(basename, group);
}

const suffixByPath = new Map<string, string>();
for (const group of groups.values()) {
const uniquePaths = [...group];
if (uniquePaths.length < 2) continue;

const parentSegmentsByPath = new Map(
uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]),
);
const minUniqueDepthByPath = new Map<string, number>();

for (const filePath of uniquePaths) {
const segments = parentSegmentsByPath.get(filePath) ?? [];
let resolvedDepth = segments.length;
for (let depth = 1; depth <= segments.length; depth += 1) {
const candidate = segments.slice(-depth).join("/");
const collision = uniquePaths.some((otherPath) => {
if (otherPath === filePath) return false;
const otherSegments = parentSegmentsByPath.get(otherPath) ?? [];
return otherSegments.slice(-depth).join("/") === candidate;
});
if (!collision) {
resolvedDepth = depth;
break;
}
}
minUniqueDepthByPath.set(filePath, resolvedDepth);
}

for (const filePath of uniquePaths) {
const segments = parentSegmentsByPath.get(filePath) ?? [];
if (segments.length === 0) continue;
const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1;
const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2));
suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/"));
}
}

return suffixByPath;
}

function extractMarkdownLinkHrefs(text: string): string[] {
const hrefs: string[] = [];
for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
const href = match[1]?.trim();
if (!href) continue;
hrefs.push(href);
}
return hrefs;
}

function normalizeMarkdownLinkHrefKey(href: string): string {
const normalizedHref = normalizeMarkdownLinkDestination(href);
return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
}

const MarkdownFileLink = memo(function MarkdownFileLink({
href,
targetPath,
Expand Down Expand Up @@ -517,9 +451,15 @@ function ChatMarkdown({
cwd,
isStreaming = false,
skills = EMPTY_MARKDOWN_SKILLS,
searchQuery = "",
searchActive = false,
}: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const searchHighlightPlugin = useMemo(
() => createThreadSearchHighlightRehypePlugin(searchQuery, { active: searchActive }),
[searchActive, searchQuery],
);
const markdownFileLinkMetaByHref = useMemo(() => {
const metaByHref = new Map<
string,
Expand Down Expand Up @@ -558,23 +498,14 @@ function ChatMarkdown({
}

const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);
const labelParts = [fileLinkMeta.basename];
if (typeof parentSuffix === "string" && parentSuffix.length > 0) {
labelParts.push(parentSuffix);
}
if (fileLinkMeta.line) {
labelParts.push(
`L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`,
);
}

return (
<MarkdownFileLink
href={fileLinkMeta.targetPath}
targetPath={fileLinkMeta.targetPath}
displayPath={fileLinkMeta.displayPath}
filePath={fileLinkMeta.filePath}
label={labelParts.join(" · ")}
label={buildMarkdownFileLinkLabel(fileLinkMeta, parentSuffix)}
theme={resolvedTheme}
className={props.className}
/>
Expand All @@ -585,6 +516,24 @@ function ChatMarkdown({
if (!codeBlock) {
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}>
Expand All @@ -608,6 +557,8 @@ function ChatMarkdown({
isStreaming,
markdownFileLinkMetaByHref,
resolvedTheme,
searchActive,
searchQuery,
skills,
],
);
Expand All @@ -616,6 +567,7 @@ function ChatMarkdown({
<div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={searchHighlightPlugin ? [searchHighlightPlugin] : []}
components={markdownComponents}
Comment thread
leonardoxr marked this conversation as resolved.
urlTransform={markdownUrlTransform}
>
Expand Down
Loading
Loading