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
58 changes: 58 additions & 0 deletions apps/web/src/components/ChatMarkdown.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,64 @@ describe("ChatMarkdown", () => {
}
});

it("renders path-shaped inline code as interactive file chips", async () => {
const source = [
"Call `appendFileSync` once per batch.",
"Inspect `apps/server/src/provider/Layers/EventNdjsonLogger.ts:148` and keep `1.2.3` unchanged.",
].join("\n\n");
const screen = await render(
<ChatMarkdown text={source} cwd="/repo/project" threadRef={threadRef} />,
);

try {
const link = page.getByRole("link", { name: "EventNdjsonLogger.ts · L148" });
await expect.element(link).toHaveClass(/chat-markdown-file-link/);
await expect
.element(link)
.toHaveAttribute(
"href",
"/repo/project/apps/server/src/provider/Layers/EventNdjsonLogger.ts:148",
);

const inlineCodeValues = [
...document.querySelectorAll<HTMLElement>(".chat-markdown :not(pre) > code"),
].map((element) => element.textContent);
expect(inlineCodeValues).toEqual(["appendFileSync", "1.2.3"]);

await link.click();
await vi.waitFor(() => {
expect(
selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef),
).toMatchObject({
isOpen: true,
activeSurfaceId: "file:apps/server/src/provider/Layers/EventNdjsonLogger.ts",
});
});
} finally {
await screen.unmount();
}
});

it("disambiguates inline-code file chips with duplicate basenames", async () => {
const screen = await render(
<ChatMarkdown
text="Compare `apps/web/src/first/config.ts` with `packages/shared/src/second/config.ts`."
cwd="/repo/project"
/>,
);

try {
await expect
.element(page.getByRole("link", { name: "config.ts · src/first" }))
.toBeInTheDocument();
await expect
.element(page.getByRole("link", { name: "config.ts · src/second" }))
.toBeInTheDocument();
} finally {
await screen.unmount();
}
});

it("renders sanitized details with the design-system collapsible", async () => {
const source = [
"<details open>",
Expand Down
56 changes: 53 additions & 3 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
import {
normalizeMarkdownLinkDestination,
resolveMarkdownFileLinkMeta,
resolveMarkdownInlineCodeFileLinkMeta,
rewriteMarkdownFileUriHref,
} from "../markdown-links";
import { readLocalApi } from "../localApi";
Expand Down Expand Up @@ -161,6 +162,8 @@ function extractPreCodeMeta(node: unknown): string | undefined {
type MarkdownAstNode = {
type?: string;
meta?: unknown;
value?: unknown;
url?: string;
data?: {
hProperties?: Record<string, unknown>;
};
Expand All @@ -186,6 +189,25 @@ function remarkPreserveCodeMeta() {
};
}

function remarkLinkInlineCodeFilePaths(options: { readonly cwd: string | undefined }) {
return (tree: MarkdownAstNode) => {
const visit = (node: MarkdownAstNode) => {
if (node.type === "inlineCode" && typeof node.value === "string") {
const value = node.value;
if (resolveMarkdownInlineCodeFileLinkMeta(value, options.cwd)) {
node.type = "link";
node.url = value.trim();
node.children = [{ type: "text", value }];
delete node.value;
}
}
node.children?.forEach(visit);
};

visit(tree);
};
}

function nodeToPlainText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
Expand Down Expand Up @@ -667,6 +689,7 @@ interface MarkdownFileLinkProps {
}

const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
const MARKDOWN_INLINE_CODE_PATTERN = /(?<!`)(`{1,2})(?!`)([\s\S]*?)(?<!`)\1(?!`)/g;
const MARKDOWN_FILE_LINK_CLASS_NAME =
"chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70";

Expand Down Expand Up @@ -740,6 +763,16 @@ function extractMarkdownLinkHrefs(text: string): string[] {
return hrefs;
}

function extractMarkdownInlineCodeValues(text: string): string[] {
const values: string[] = [];
for (const match of text.matchAll(MARKDOWN_INLINE_CODE_PATTERN)) {
const value = match[2]?.trim();
if (!value) continue;
values.push(value);
}
return values;
}

function normalizeMarkdownLinkHrefKey(href: string): string {
const normalizedHref = normalizeMarkdownLinkDestination(href);
return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
Expand Down Expand Up @@ -1190,6 +1223,14 @@ function ChatMarkdown({
metaByHref.set(normalizedHref, meta);
}
}
for (const value of extractMarkdownInlineCodeValues(text)) {
const normalizedHref = normalizeMarkdownLinkHrefKey(value);
if (metaByHref.has(normalizedHref)) continue;
const meta = resolveMarkdownInlineCodeFileLinkMeta(normalizedHref, cwd);
if (meta) {
metaByHref.set(normalizedHref, meta);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fenced backticks pollute chip labels

Medium Severity

extractMarkdownInlineCodeValues scans the full markdown string with a backtick regex, so path-like spans inside fenced code blocks are counted even though remarkLinkInlineCodeFilePaths never turns them into links. Those phantom paths feed buildFileLinkParentSuffixByPath, which can add basename suffixes to the only real file chip when none were intended.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5350fea. Configure here.

return metaByHref;
}, [cwd, text]);
const fileLinkParentSuffixByPath = useMemo(() => {
Expand Down Expand Up @@ -1220,7 +1261,10 @@ function ChatMarkdown({
},
a({ node, href, children, ...props }) {
const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
const fileLinkMeta = normalizedHref
? (markdownFileLinkMetaByHref.get(normalizedHref) ??
resolveMarkdownFileLinkMeta(normalizedHref, cwd))
: null;
if (!fileLinkMeta) {
const faviconHost = resolveExternalLinkHost(href);
const isSameDocumentLink = href?.startsWith("#") ?? false;
Expand Down Expand Up @@ -1327,6 +1371,7 @@ function ChatMarkdown({
}),
[
diffThemeName,
cwd,
fileLinkParentSuffixByPath,
isStreaming,
markdownFileLinkMetaByHref,
Expand All @@ -1347,8 +1392,13 @@ function ChatMarkdown({
<ReactMarkdown
remarkPlugins={
lineBreaks
? [remarkGfm, remarkBreaks, remarkPreserveCodeMeta]
: [remarkGfm, remarkPreserveCodeMeta]
? [
remarkGfm,
remarkBreaks,
[remarkLinkInlineCodeFilePaths, { cwd }],
remarkPreserveCodeMeta,
]
: [remarkGfm, [remarkLinkInlineCodeFilePaths, { cwd }], remarkPreserveCodeMeta]
}
rehypePlugins={[rehypeRaw, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA]]}
components={markdownComponents}
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/markdown-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
resolveMarkdownFileLinkMeta,
resolveMarkdownFileLinkTarget,
resolveMarkdownInlineCodeFileLinkMeta,
rewriteMarkdownFileUriHref,
} from "./markdown-links";

Expand Down Expand Up @@ -127,3 +128,35 @@ describe("resolveMarkdownFileLinkTarget", () => {
expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull();
});
});

describe("resolveMarkdownInlineCodeFileLinkMeta", () => {
it("resolves path-shaped inline code with source positions", () => {
expect(
resolveMarkdownInlineCodeFileLinkMeta(
"apps/server/src/provider/Layers/EventNdjsonLogger.ts:148",
"/repo/project",
),
).toMatchObject({
basename: "EventNdjsonLogger.ts",
line: 148,
targetPath: "/repo/project/apps/server/src/provider/Layers/EventNdjsonLogger.ts:148",
workspaceRelativePath: "apps/server/src/provider/Layers/EventNdjsonLogger.ts",
});
});

it("resolves bare filenames only when they include a source position", () => {
expect(resolveMarkdownInlineCodeFileLinkMeta("script.ts:10", "/repo/project")).toMatchObject({
basename: "script.ts",
line: 10,
targetPath: "/repo/project/script.ts:10",
});
expect(resolveMarkdownInlineCodeFileLinkMeta("script.ts", "/repo/project")).toBeNull();
});

it("leaves ordinary code and ambiguous slash values alone", () => {
expect(resolveMarkdownInlineCodeFileLinkMeta("sink.write()", "/repo/project")).toBeNull();
expect(resolveMarkdownInlineCodeFileLinkMeta("1.2.3", "/repo/project")).toBeNull();
expect(resolveMarkdownInlineCodeFileLinkMeta("1/2", "/repo/project")).toBeNull();
expect(resolveMarkdownInlineCodeFileLinkMeta("client/server", "/repo/project")).toBeNull();
});
});
25 changes: 25 additions & 0 deletions apps/web/src/markdown-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,28 @@ export function resolveMarkdownFileLinkMeta(
...(columnNumber !== undefined ? { column: columnNumber } : {}),
};
}

/**
* Inline code is used for many non-path values, so only promote references
* with unambiguous path syntax. Markdown links remain more permissive because
* the link destination itself already signals intent.
*/
export function resolveMarkdownInlineCodeFileLinkMeta(
value: string,
cwd?: string,
): MarkdownFileLinkMeta | null {
const candidate = value.trim();
const hasSourcePosition =
POSITION_SUFFIX_PATTERN.test(candidate) || /#L\d+(?:C\d+)?$/i.test(candidate);
if (!/[\\/]/.test(candidate) && !hasSourcePosition) {
return null;
}
const meta = resolveMarkdownFileLinkMeta(candidate, cwd);
if (!meta) {
return null;
}
if (!/[A-Za-z]/.test(meta.basename) || (!meta.basename.includes(".") && !hasSourcePosition)) {
return null;
}
return meta;
}
Loading