From f5bfb0c65aca6006f5fbbf425f8b17940023b7c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 28 Jul 2026 12:30:43 +0200 Subject: [PATCH 1/7] feat(web): link inline code file paths - Detect and render eligible inline code paths as file links - Add coverage for path, position, and false-positive cases --- apps/web/src/components/ChatMarkdown.tsx | 200 +++++++++++++----- .../src/components/chat/SkillInlineText.tsx | 7 +- apps/web/src/markdown-links.test.ts | 69 ++++++ apps/web/src/markdown-links.ts | 36 ++++ 4 files changed, 256 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index fac3bf7d245..0f17455f489 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -67,8 +67,10 @@ import { import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { normalizeMarkdownLinkDestination, + resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, + type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; @@ -162,7 +164,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), - code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"], + code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], }, protocols: { ...defaultSchema.protocols, @@ -174,6 +176,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, + remarkTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ @@ -181,6 +184,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, + remarkTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ @@ -253,6 +257,30 @@ function remarkPreserveCodeMeta() { }; } +/** + * Fenced code also lands on the `code` component, and inline vs block is no + * longer distinguishable there once both render `` — so inline spans are + * tagged on the mdast, where the distinction still exists. + */ +function remarkTagInlineCode() { + return (tree: MarkdownAstNode) => { + const visit = (node: MarkdownAstNode) => { + if (node.type === "inlineCode") { + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + dataInlineCode: "", + }, + }; + } + node.children?.forEach(visit); + }; + + visit(tree); + }; +} + function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); @@ -276,11 +304,17 @@ function extractCodeBlock( const onlyChild = childNodes[0]; if ( - !isValidElement<{ className?: string; children?: ReactNode }>(onlyChild) || - onlyChild.type !== "code" + !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( + onlyChild, + ) ) { return null; } + // With a custom `code` component the child's type is that component, not + // the "code" tag — the hast node react-markdown attaches still names it. + if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") { + return null; + } return { className: onlyChild.props.className, @@ -816,6 +850,21 @@ function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map< return suffixByPath; } +const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; +const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g; + +function extractInlineCodeSpans(text: string): string[] { + const spans: string[] = []; + const segments = text.split(FENCED_CODE_SEGMENT_PATTERN); + for (let index = 0; index < segments.length; index += 2) { + for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) { + const span = match[1]?.trim(); + if (span) spans.push(span); + } + } + return spans; +} + function extractMarkdownLinkHrefs(text: string): string[] { const hrefs: string[] = []; for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { @@ -1281,10 +1330,24 @@ function ChatMarkdown({ } return metaByHref; }, [cwd, text]); + const inlineCodeFileLinkMetaByText = useMemo(() => { + const metaByText = new Map(); + for (const span of extractInlineCodeSpans(text)) { + if (metaByText.has(span)) continue; + const meta = resolveInlineCodeFileLinkMeta(span, cwd); + if (meta) { + metaByText.set(span, meta); + } + } + return metaByText; + }, [cwd, text]); const fileLinkParentSuffixByPath = useMemo(() => { - const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); + const filePaths = [ + ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), + ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), + ]; return buildFileLinkParentSuffixByPath(filePaths); - }, [markdownFileLinkMetaByHref]); + }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); @@ -1339,8 +1402,49 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); - const markdownComponents = useMemo( - () => ({ + const markdownComponents = useMemo(() => { + const fileLinkChip = ( + fileLinkMeta: MarkdownFileLinkMeta, + copyMarkdown: string, + className?: string, + ) => { + 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 ( + openMarkdownFileInPreview(fileLinkMeta.filePath) + : undefined + } + className={className} + /> + ); + }; + + return { p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, @@ -1455,39 +1559,26 @@ 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 fileLinkChip( + fileLinkMeta, + `[${fileLinkMeta.basename}](${normalizedHref})`, + props.className, + ); + }, + code({ node, children, className, ...props }) { + if (node?.properties?.dataInlineCode != null) { + const codeText = nodeToPlainText(children); + const fileLinkMeta = + inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? + resolveInlineCodeFileLinkMeta(codeText, cwd); + if (fileLinkMeta) { + return fileLinkChip(fileLinkMeta, `\`${codeText}\``); + } } - return ( - openMarkdownFileInPreview(fileLinkMeta.filePath) - : undefined - } - className={props.className} - /> + + {children} + ); }, table({ node: _node, ...props }) { @@ -1524,22 +1615,23 @@ function ChatMarkdown({ ); }, - }), - [ - diffThemeName, - fileLinkParentSuffixByPath, - isStreaming, - markdownFileLinkMetaByHref, - onTaskListChange, - openInPreferredEditor, - openExternalLinkInPreview, - openMarkdownFileInPreview, - resolvedTheme, - skills, - text, - threadRef, - ], - ); + }; + }, [ + cwd, + diffThemeName, + fileLinkParentSuffixByPath, + inlineCodeFileLinkMetaByText, + isStreaming, + markdownFileLinkMetaByHref, + onTaskListChange, + openInPreferredEditor, + openExternalLinkInPreview, + openMarkdownFileInPreview, + resolvedTheme, + skills, + text, + threadRef, + ]); return (
; } - if (!isValidElement<{ children?: ReactNode }>(child)) { + if (!isValidElement<{ children?: ReactNode; node?: { tagName?: string } }>(child)) { return child; } - if (child.type === "code" || child.type === "a") { + // Custom react-markdown components replace the intrinsic type, so also + // check the hast node they carry. + const markdownTagName = typeof child.type === "string" ? child.type : child.props.node?.tagName; + if (markdownTagName === "code" || markdownTagName === "a") { return child; } if (!("children" in child.props)) { diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 5691ffa8895..2687b6da40b 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref, @@ -127,3 +128,71 @@ describe("resolveMarkdownFileLinkTarget", () => { expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull(); }); }); + +describe("resolveInlineCodeFileLinkMeta", () => { + it("links relative paths with file extensions", () => { + expect( + resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md", "/Users/julius/project"), + ).toMatchObject({ + targetPath: "/Users/julius/project/.plans/worktree-management-v1.md", + basename: "worktree-management-v1.md", + }); + }); + + it("links absolute posix paths", () => { + expect(resolveInlineCodeFileLinkMeta("/Users/julius/project/AGENTS.md")).toMatchObject({ + targetPath: "/Users/julius/project/AGENTS.md", + }); + }); + + it("links windows drive paths", () => { + expect(resolveInlineCodeFileLinkMeta("C:\\Users\\mike\\project\\src\\main.ts")).toMatchObject({ + basename: "main.ts", + }); + }); + + it("links relative paths with line positions", () => { + expect( + resolveInlineCodeFileLinkMeta("src/processRunner.ts:71", "/Users/julius/project"), + ).toMatchObject({ + targetPath: "/Users/julius/project/src/processRunner.ts:71", + line: 71, + }); + }); + + it("links bare filenames only when a line suffix marks them as file references", () => { + expect(resolveInlineCodeFileLinkMeta("script.ts:10", "/Users/julius/project")).toMatchObject({ + targetPath: "/Users/julius/project/script.ts:10", + line: 10, + }); + expect(resolveInlineCodeFileLinkMeta("AGENTS.md", "/Users/julius/project")).toBeNull(); + }); + + it("links dot-prefixed relative paths without extensions", () => { + expect( + resolveInlineCodeFileLinkMeta("./scripts/deploy", "/Users/julius/project"), + ).toMatchObject({ + basename: "deploy", + }); + }); + + it("ignores commands, flags, and expressions", () => { + expect(resolveInlineCodeFileLinkMeta("git worktree list --porcelain")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("node.meta", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("pnpm install", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("src/**/*.ts", "/Users/julius/project")).toBeNull(); + }); + + it("ignores extension-less relative segments like git refs and directories", () => { + expect(resolveInlineCodeFileLinkMeta("origin/main", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("apps/web", "/Users/julius/project")).toBeNull(); + }); + + it("ignores external urls", () => { + expect(resolveInlineCodeFileLinkMeta("https://example.com/docs.html")).toBeNull(); + }); + + it("ignores relative paths without a cwd to resolve against", () => { + expect(resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md")).toBeNull(); + }); +}); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 1e24de8bb1d..cdd64ec0093 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -170,6 +170,42 @@ export function resolveMarkdownFileLinkTarget( return resolvePathLinkTarget(pathWithPosition, cwd); } +const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; +const PATH_SEPARATOR_PATTERN = /[\\/]/; +const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; + +/** + * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`, + * `origin/main`) rather than deliberate link destinations, so auto-linking + * them demands stronger path evidence than an explicit markdown link does: + * an unambiguous path prefix, a file extension, or a :line suffix. + */ +export function resolveInlineCodeFileLinkMeta( + codeText: string, + cwd?: string, +): MarkdownFileLinkMeta | null { + const candidate = codeText.trim(); + if (candidate.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(candidate)) return null; + + const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); + if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; + + const hasExplicitPathShape = + RELATIVE_PATH_PREFIX_PATTERN.test(candidate) || + candidate.startsWith("/") || + WINDOWS_DRIVE_PATH_PATTERN.test(candidate) || + WINDOWS_UNC_PATH_PATTERN.test(candidate); + if ( + !hasPosition && + !hasExplicitPathShape && + !FILE_EXTENSION_PATTERN.test(basenameOfPath(candidate)) + ) { + return null; + } + + return resolveMarkdownFileLinkMeta(candidate, cwd); +} + function basenameOfPath(path: string): string { const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; From f11caa4736e63f989cb4e2e36715d4482c75c08c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 28 Jul 2026 12:49:08 +0200 Subject: [PATCH 2/7] fix(web): address inline code link review findings - Normalize backslashes in non-drive/UNC candidates so relative Windows-style paths (src\main.ts, .\scripts\deploy) linkify - Reject hostname-shaped candidates (127.0.0.1:3000, example.com/index.html) that previously became file chips - Skip tagging inline code inside markdown link labels to avoid nested anchors stealing the outer link's clicks Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ChatMarkdown.tsx | 13 +++-- apps/web/src/markdown-links.test.ts | 27 +++++++++++ apps/web/src/markdown-links.ts | 62 +++++++++++++++++++++--- 3 files changed, 89 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 0f17455f489..d86fe39a77f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -260,12 +260,14 @@ function remarkPreserveCodeMeta() { /** * Fenced code also lands on the `code` component, and inline vs block is no * longer distinguishable there once both render `` — so inline spans are - * tagged on the mdast, where the distinction still exists. + * tagged on the mdast, where the distinction still exists. Code inside a link + * label stays untagged: linkifying it would nest an anchor inside the link's + * anchor and steal its clicks. */ function remarkTagInlineCode() { return (tree: MarkdownAstNode) => { - const visit = (node: MarkdownAstNode) => { - if (node.type === "inlineCode") { + const visit = (node: MarkdownAstNode, insideLink: boolean) => { + if (node.type === "inlineCode" && !insideLink) { node.data = { ...node.data, hProperties: { @@ -274,10 +276,11 @@ function remarkTagInlineCode() { }, }; } - node.children?.forEach(visit); + const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference"; + node.children?.forEach((child) => visit(child, childInsideLink)); }; - visit(tree); + visit(tree, false); }; } diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 2687b6da40b..de2a8f678a3 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -176,6 +176,33 @@ describe("resolveInlineCodeFileLinkMeta", () => { }); }); + it("links relative windows-style paths by normalizing backslashes", () => { + expect(resolveInlineCodeFileLinkMeta("src\\main.ts", "/Users/julius/project")).toMatchObject({ + targetPath: "/Users/julius/project/src/main.ts", + basename: "main.ts", + }); + expect( + resolveInlineCodeFileLinkMeta(".\\scripts\\deploy", "/Users/julius/project"), + ).toMatchObject({ + basename: "deploy", + }); + }); + + it("ignores hosts, ports, and versions", () => { + expect(resolveInlineCodeFileLinkMeta("127.0.0.1:3000", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("localhost:3000", "/Users/julius/project")).toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("example.com/index.html", "/Users/julius/project"), + ).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("example.com:8080", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("10.0.0.1:80:1", "/Users/julius/project")).toBeNull(); + }); + + it("still links files whose extension merely resembles a tld", () => { + expect(resolveInlineCodeFileLinkMeta("script.ts:10", "/Users/julius/project")).not.toBeNull(); + expect(resolveInlineCodeFileLinkMeta("src/setup.sh:3", "/Users/julius/project")).not.toBeNull(); + }); + it("ignores commands, flags, and expressions", () => { expect(resolveInlineCodeFileLinkMeta("git worktree list --porcelain")).toBeNull(); expect(resolveInlineCodeFileLinkMeta("node.meta", "/Users/julius/project")).toBeNull(); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index cdd64ec0093..4b7d05ae910 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -173,6 +173,43 @@ export function resolveMarkdownFileLinkTarget( const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; +const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; +const COMMON_HOSTNAME_TLDS = new Set([ + "com", + "net", + "org", + "io", + "dev", + "app", + "ai", + "co", + "edu", + "gov", + "mil", + "info", + "biz", + "xyz", + "me", + "tv", + "cc", + "gg", + "chat", + "cloud", + "site", + "online", + "tech", + "store", + "link", +]); + +/** `127.0.0.1`, `example.com`, `1.2.3` — hosts and versions, not files. */ +function looksLikeHostname(segment: string): boolean { + if (segment.startsWith(".")) return false; + if (NUMERIC_DOTTED_PATTERN.test(segment)) return true; + const labels = segment.toLowerCase().split("."); + const lastLabel = labels[labels.length - 1]; + return labels.length >= 2 && lastLabel !== undefined && COMMON_HOSTNAME_TLDS.has(lastLabel); +} /** * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`, @@ -184,8 +221,16 @@ export function resolveInlineCodeFileLinkMeta( codeText: string, cwd?: string, ): MarkdownFileLinkMeta | null { - const candidate = codeText.trim(); - if (candidate.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(candidate)) return null; + const trimmed = codeText.trim(); + if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null; + + // Windows drive/UNC paths keep their backslashes; any other backslashes are + // relative Windows-style paths, which neither the shape checks nor the + // downstream resolver understand — normalize them to forward slashes. + const candidate = + WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed) + ? trimmed + : trimmed.replaceAll("\\", "/"); const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate); if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null; @@ -195,12 +240,13 @@ export function resolveInlineCodeFileLinkMeta( candidate.startsWith("/") || WINDOWS_DRIVE_PATH_PATTERN.test(candidate) || WINDOWS_UNC_PATH_PATTERN.test(candidate); - if ( - !hasPosition && - !hasExplicitPathShape && - !FILE_EXTENSION_PATTERN.test(basenameOfPath(candidate)) - ) { - return null; + if (!hasExplicitPathShape) { + const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); + const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; + if (looksLikeHostname(firstSegment)) return null; + if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basenameOfPath(withoutPosition))) { + return null; + } } return resolveMarkdownFileLinkMeta(candidate, cwd); From 5aa55ffb69cc460ac56d70ce738efad9e54cc4a6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 28 Jul 2026 12:55:10 +0200 Subject: [PATCH 3/7] fix(web): broaden inline code hostname detection Reject single-label hosts (localhost) and common country TLDs so localhost/index.html and example.uk/index.html stay plain code. Kept as an allowlist rather than treating every dotted segment as a host, which would swallow real paths like conf.d/x.conf and Makefile.in:12. Co-Authored-By: Claude Fable 5 --- apps/web/src/markdown-links.test.ts | 10 +++++++ apps/web/src/markdown-links.ts | 42 +++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index de2a8f678a3..248b2fbafec 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -196,11 +196,21 @@ describe("resolveInlineCodeFileLinkMeta", () => { ).toBeNull(); expect(resolveInlineCodeFileLinkMeta("example.com:8080", "/Users/julius/project")).toBeNull(); expect(resolveInlineCodeFileLinkMeta("10.0.0.1:80:1", "/Users/julius/project")).toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("localhost/index.html", "/Users/julius/project"), + ).toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("example.uk/index.html", "/Users/julius/project"), + ).toBeNull(); }); it("still links files whose extension merely resembles a tld", () => { expect(resolveInlineCodeFileLinkMeta("script.ts:10", "/Users/julius/project")).not.toBeNull(); expect(resolveInlineCodeFileLinkMeta("src/setup.sh:3", "/Users/julius/project")).not.toBeNull(); + expect(resolveInlineCodeFileLinkMeta("Makefile.in:12", "/Users/julius/project")).not.toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("conf.d/nginx.conf", "/Users/julius/project"), + ).not.toBeNull(); }); it("ignores commands, flags, and expressions", () => { diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 4b7d05ae910..d655a494b47 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -174,6 +174,11 @@ const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; +const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]); +// An allowlist, not full public-suffix detection: treating every dotted first +// segment as a host would swallow real paths like `conf.d/x.conf` or +// `Makefile.in:12`. Extensions that double as filename suffixes (`sh`, `md`, +// `ts`, `rs`, `in`, ...) are deliberately absent. const COMMON_HOSTNAME_TLDS = new Set([ "com", "net", @@ -200,13 +205,46 @@ const COMMON_HOSTNAME_TLDS = new Set([ "tech", "store", "link", + "uk", + "de", + "fr", + "nl", + "se", + "no", + "fi", + "dk", + "pl", + "ch", + "at", + "be", + "es", + "it", + "pt", + "eu", + "us", + "ca", + "au", + "nz", + "jp", + "kr", + "cn", + "br", + "ru", + "mx", + "ie", + "cz", + "tr", + "sg", + "hk", ]); -/** `127.0.0.1`, `example.com`, `1.2.3` — hosts and versions, not files. */ +/** `127.0.0.1`, `localhost`, `example.com`, `1.2.3` — hosts and versions, not files. */ function looksLikeHostname(segment: string): boolean { if (segment.startsWith(".")) return false; + const lowered = segment.toLowerCase(); + if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true; if (NUMERIC_DOTTED_PATTERN.test(segment)) return true; - const labels = segment.toLowerCase().split("."); + const labels = lowered.split("."); const lastLabel = labels[labels.length - 1]; return labels.length >= 2 && lastLabel !== undefined && COMMON_HOSTNAME_TLDS.has(lastLabel); } From 3986bf2851b2b2e6d729b50a5d3147021e9e3394 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 28 Jul 2026 12:59:14 +0200 Subject: [PATCH 4/7] fix(web): let line suffixes outrank country-code tlds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the hostname allowlist: generic TLDs (com, io, dev, ...) always count as host evidence, but country codes double as file extensions (.pl, .pt, .es), so they yield when the candidate carries an explicit :line suffix — script.pl:10 links again while example.pl/index.html and example.com:8080 stay plain code. Co-Authored-By: Claude Fable 5 --- apps/web/src/markdown-links.test.ts | 12 ++++++++++++ apps/web/src/markdown-links.ts | 19 +++++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 248b2fbafec..e309b864d4b 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -213,6 +213,18 @@ describe("resolveInlineCodeFileLinkMeta", () => { ).not.toBeNull(); }); + it("prefers file over country-code host when a line suffix is present", () => { + expect(resolveInlineCodeFileLinkMeta("script.pl:10", "/Users/julius/project")).toMatchObject({ + targetPath: "/Users/julius/project/script.pl:10", + line: 10, + }); + expect(resolveInlineCodeFileLinkMeta("model.pt:3", "/Users/julius/project")).not.toBeNull(); + expect( + resolveInlineCodeFileLinkMeta("example.pl/index.html", "/Users/julius/project"), + ).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("example.com:8080", "/Users/julius/project")).toBeNull(); + }); + it("ignores commands, flags, and expressions", () => { expect(resolveInlineCodeFileLinkMeta("git worktree list --porcelain")).toBeNull(); expect(resolveInlineCodeFileLinkMeta("node.meta", "/Users/julius/project")).toBeNull(); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index d655a494b47..b329e5751bd 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -175,11 +175,11 @@ const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]); -// An allowlist, not full public-suffix detection: treating every dotted first +// Allowlists, not full public-suffix detection: treating every dotted first // segment as a host would swallow real paths like `conf.d/x.conf` or // `Makefile.in:12`. Extensions that double as filename suffixes (`sh`, `md`, -// `ts`, `rs`, `in`, ...) are deliberately absent. -const COMMON_HOSTNAME_TLDS = new Set([ +// `ts`, `rs`, `in`, ...) are deliberately absent from both sets. +const GENERIC_HOSTNAME_TLDS = new Set([ "com", "net", "org", @@ -205,6 +205,11 @@ const COMMON_HOSTNAME_TLDS = new Set([ "tech", "store", "link", +]); +// Country codes collide with file extensions (`.pl` Perl, `.pt` PyTorch, +// `.es` ES modules), so they only count as host evidence when the candidate +// lacks a :line suffix — an explicit line reference marks a file and wins. +const COUNTRY_HOSTNAME_TLDS = new Set([ "uk", "de", "fr", @@ -239,14 +244,16 @@ const COMMON_HOSTNAME_TLDS = new Set([ ]); /** `127.0.0.1`, `localhost`, `example.com`, `1.2.3` — hosts and versions, not files. */ -function looksLikeHostname(segment: string): boolean { +function looksLikeHostname(segment: string, hasPosition: boolean): boolean { if (segment.startsWith(".")) return false; const lowered = segment.toLowerCase(); if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true; if (NUMERIC_DOTTED_PATTERN.test(segment)) return true; const labels = lowered.split("."); const lastLabel = labels[labels.length - 1]; - return labels.length >= 2 && lastLabel !== undefined && COMMON_HOSTNAME_TLDS.has(lastLabel); + if (labels.length < 2 || lastLabel === undefined) return false; + if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true; + return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel); } /** @@ -281,7 +288,7 @@ export function resolveInlineCodeFileLinkMeta( if (!hasExplicitPathShape) { const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, ""); const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition; - if (looksLikeHostname(firstSegment)) return null; + if (looksLikeHostname(firstSegment, hasPosition)) return null; if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basenameOfPath(withoutPosition))) { return null; } From ef70c7999efe86224b4f25a09037a6bc0f48e539 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 28 Jul 2026 13:20:02 +0200 Subject: [PATCH 5/7] fix(web): link extensionless bare filenames with line refs Makefile:12, Dockerfile:8, LICENSE:3 passed the inline-code gate but were rejected by the generic candidate patterns, which require a separator or dotted extension. Resolve that shape directly against the cwd, scoped to inline code so explicit markdown-link behavior is unchanged. Co-Authored-By: Claude Fable 5 --- apps/web/src/markdown-links.test.ts | 13 +++++++++++++ apps/web/src/markdown-links.ts | 15 ++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index e309b864d4b..6e017e3b791 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -168,6 +168,19 @@ describe("resolveInlineCodeFileLinkMeta", () => { expect(resolveInlineCodeFileLinkMeta("AGENTS.md", "/Users/julius/project")).toBeNull(); }); + it("links extensionless bare filenames with a line suffix", () => { + expect(resolveInlineCodeFileLinkMeta("Makefile:12", "/Users/julius/project")).toMatchObject({ + targetPath: "/Users/julius/project/Makefile:12", + basename: "Makefile", + line: 12, + }); + expect(resolveInlineCodeFileLinkMeta("Dockerfile:8:2", "/Users/julius/project")).toMatchObject({ + line: 8, + column: 2, + }); + expect(resolveInlineCodeFileLinkMeta("Makefile:12")).toBeNull(); + }); + it("links dot-prefixed relative paths without extensions", () => { expect( resolveInlineCodeFileLinkMeta("./scripts/deploy", "/Users/julius/project"), diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index b329e5751bd..b061659b6c8 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -174,6 +174,7 @@ const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; +const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]); // Allowlists, not full public-suffix detection: treating every dotted first // segment as a host would swallow real paths like `conf.d/x.conf` or @@ -294,7 +295,16 @@ export function resolveInlineCodeFileLinkMeta( } } - return resolveMarkdownFileLinkMeta(candidate, cwd); + const resolved = resolveMarkdownFileLinkMeta(candidate, cwd); + if (resolved) return resolved; + + // `Makefile:12` — extensionless bare names fail the generic markdown-link + // candidate patterns, but here the :line suffix already marked the span as + // a file reference. + if (cwd && BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate)) { + return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd); + } + return null; } function basenameOfPath(path: string): string { @@ -321,7 +331,10 @@ export function resolveMarkdownFileLinkMeta( ): MarkdownFileLinkMeta | null { const targetPath = resolveMarkdownFileLinkTarget(href, cwd); if (!targetPath) return null; + return buildFileLinkMetaFromTarget(targetPath, cwd); +} +function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta { const { path, line, column } = splitPathAndPosition(targetPath); const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN; const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN; From 41fed10b470e2c5b342dcf4a49b3e3db8cc89498 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 28 Jul 2026 13:25:25 +0200 Subject: [PATCH 6/7] fix(web): limit extensionless line refs to conventional filenames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any Name:digits shape also matches error:1, port:3000, TODO:12 — gate the bare extensionless fallback behind an allowlist of conventional filenames (Makefile, Dockerfile, LICENSE, ...). Co-Authored-By: Claude Fable 5 --- apps/web/src/markdown-links.test.ts | 8 ++++++ apps/web/src/markdown-links.ts | 43 ++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 6e017e3b791..f21f82292f0 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -181,6 +181,14 @@ describe("resolveInlineCodeFileLinkMeta", () => { expect(resolveInlineCodeFileLinkMeta("Makefile:12")).toBeNull(); }); + it("does not treat arbitrary name:digits shapes as files", () => { + expect(resolveInlineCodeFileLinkMeta("error:1", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("TODO:12", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("exit:0", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("port:3000", "/Users/julius/project")).toBeNull(); + expect(resolveInlineCodeFileLinkMeta("http:80", "/Users/julius/project")).toBeNull(); + }); + it("links dot-prefixed relative paths without extensions", () => { expect( resolveInlineCodeFileLinkMeta("./scripts/deploy", "/Users/julius/project"), diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index b061659b6c8..3d98ec7b3a8 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -175,6 +175,37 @@ const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; +// Any `Name:digits` shape also matches `error:1`, `port:3000`, `TODO:12`, so +// extensionless linking is limited to conventional filenames. +const EXTENSIONLESS_FILE_NAMES = new Set([ + "Makefile", + "makefile", + "GNUmakefile", + "Dockerfile", + "Containerfile", + "Justfile", + "justfile", + "Rakefile", + "Gemfile", + "Procfile", + "Brewfile", + "Caddyfile", + "Vagrantfile", + "Jenkinsfile", + "Podfile", + "Fastfile", + "BUILD", + "WORKSPACE", + "LICENSE", + "LICENCE", + "COPYING", + "NOTICE", + "AUTHORS", + "CONTRIBUTORS", + "CHANGELOG", + "README", + "CODEOWNERS", +]); const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]); // Allowlists, not full public-suffix detection: treating every dotted first // segment as a host would swallow real paths like `conf.d/x.conf` or @@ -298,10 +329,14 @@ export function resolveInlineCodeFileLinkMeta( const resolved = resolveMarkdownFileLinkMeta(candidate, cwd); if (resolved) return resolved; - // `Makefile:12` — extensionless bare names fail the generic markdown-link - // candidate patterns, but here the :line suffix already marked the span as - // a file reference. - if (cwd && BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate)) { + // `Makefile:12` — conventional extensionless names fail the generic + // markdown-link candidate patterns, but here the :line suffix already + // marked the span as a file reference. + if ( + cwd && + BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate) && + EXTENSIONLESS_FILE_NAMES.has(candidate.replace(POSITION_SUFFIX_PATTERN, "")) + ) { return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd); } return null; From c09f076819a84079c94edc71ac8de35e36d13f0c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 28 Jul 2026 13:29:00 +0200 Subject: [PATCH 7/7] fix(web): broaden posix roots for absolute inline paths /usr/local/bin/tool and /workspace/Makefile failed the posix root allowlist and needed an extension or :line to link. Add standard OS and dev-container roots while still excluding app-route-ish prefixes so /chat/settings never reads as a file. Co-Authored-By: Claude Fable 5 --- apps/web/src/markdown-links.test.ts | 7 +++++++ apps/web/src/markdown-links.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index f21f82292f0..9fc29613867 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -143,6 +143,13 @@ describe("resolveInlineCodeFileLinkMeta", () => { expect(resolveInlineCodeFileLinkMeta("/Users/julius/project/AGENTS.md")).toMatchObject({ targetPath: "/Users/julius/project/AGENTS.md", }); + expect(resolveInlineCodeFileLinkMeta("/usr/local/bin/tool")).toMatchObject({ + targetPath: "/usr/local/bin/tool", + }); + expect(resolveInlineCodeFileLinkMeta("/workspace/Makefile")).toMatchObject({ + basename: "Makefile", + }); + expect(resolveInlineCodeFileLinkMeta("/chat/settings")).toBeNull(); }); it("links windows drive paths", () => { diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 3d98ec7b3a8..a6dba941b8a 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -9,6 +9,8 @@ const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/; +// Standard OS and dev-container roots; deliberately excludes app-route-ish +// prefixes like /app/ or /chat/ so SPA routes never read as files. const POSIX_FILE_ROOT_PREFIXES = [ "/Users/", "/home/", @@ -20,6 +22,20 @@ const POSIX_FILE_ROOT_PREFIXES = [ "/Volumes/", "/private/", "/root/", + "/usr/", + "/bin/", + "/sbin/", + "/lib/", + "/lib64/", + "/srv/", + "/dev/", + "/proc/", + "/sys/", + "/run/", + "/boot/", + "/media/", + "/workspace/", + "/workspaces/", ] as const; export interface MarkdownFileLinkMeta {