diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx
index 4eeab3e8075..01b8ff82b67 100644
--- a/apps/web/src/components/ChatMarkdown.browser.tsx
+++ b/apps/web/src/components/ChatMarkdown.browser.tsx
@@ -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(
+ ,
+ );
+
+ 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(".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(
+ ,
+ );
+
+ 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 = [
"",
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index ecd6bc40ffe..95fc2838cab 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -54,6 +54,7 @@ import {
import {
normalizeMarkdownLinkDestination,
resolveMarkdownFileLinkMeta,
+ resolveMarkdownInlineCodeFileLinkMeta,
rewriteMarkdownFileUriHref,
} from "../markdown-links";
import { readLocalApi } from "../localApi";
@@ -161,6 +162,8 @@ function extractPreCodeMeta(node: unknown): string | undefined {
type MarkdownAstNode = {
type?: string;
meta?: unknown;
+ value?: unknown;
+ url?: string;
data?: {
hProperties?: Record;
};
@@ -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);
@@ -667,6 +689,7 @@ interface MarkdownFileLinkProps {
}
const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
+const MARKDOWN_INLINE_CODE_PATTERN = /(? {
@@ -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;
@@ -1327,6 +1371,7 @@ function ChatMarkdown({
}),
[
diffThemeName,
+ cwd,
fileLinkParentSuffixByPath,
isStreaming,
markdownFileLinkMetaByHref,
@@ -1347,8 +1392,13 @@ function ChatMarkdown({
{
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();
+ });
+});
diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts
index 1e24de8bb1d..b112c6c8e69 100644
--- a/apps/web/src/markdown-links.ts
+++ b/apps/web/src/markdown-links.ts
@@ -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;
+}