From 5350fea9387b10d1499a1f2386a7b2af71015844 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 17 Jun 2026 16:10:13 -0700 Subject: [PATCH] Render path-like inline code as file chips - Promote unambiguous inline code paths to clickable file chips - Add resolution tests for line-suffixed paths and disambiguation --- .../src/components/ChatMarkdown.browser.tsx | 58 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 56 +++++++++++++++++- apps/web/src/markdown-links.test.ts | 33 +++++++++++ apps/web/src/markdown-links.ts | 25 ++++++++ 4 files changed, 169 insertions(+), 3 deletions(-) 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; +}