Restore inline-code file path links (#7251)

* fix(app): restore inline-code file path links

Classify complete inline-code spans with the file-link classifier before adapting them to the Markdown engine recognition pattern. Keep bare-text heuristics separate so route punctuation and line fragments work without adding prose false positives.

* perf(app): avoid parsing inline-link source on every reveal

Collect candidate values between equal-length backtick runs and let the Markdown engine own code boundaries. This preserves classifier decisions without reparsing long fenced blocks in JavaScript during streaming.
This commit is contained in:
Mohamed Boudra
2026-10-18 00:30:19 +07:00
committed by GitHub
parent a4eb0adcf6
commit 3c48ac3ad2
3 changed files with 217 additions and 15 deletions
@@ -188,3 +188,74 @@ async function measureInlineCode(message: Locator) {
return { codes, right: paragraph.getBoundingClientRect().right };
});
}
test("inline-code paths preserve stable recognition and file navigation", async ({
page,
context,
}, testInfo) => {
const { writeFile, mkdir } = await import("node:fs/promises");
const paths = ["src/app/[id].tsx", "src/app/(tabs)/index.tsx", "src/a$b.ts"];
const source =
paths.map((path) => `Open \`${path}\`.`).join("\n\n") +
"\n\nOpen `.gitignore` and `src/index.ts#L10-L20`. Compare before/after and `before/after`. Keep `word.word` plain.";
const session = await seedMockAgentWorkspace({
repoPrefix: "inline-path-regression-",
title: "Inline path regression",
initialPrompt: "Show file paths",
featureValues: { mockAssistantResponse: source },
});
try {
await mkdir(`${session.cwd}/src/app/(tabs)`, { recursive: true });
for (const path of paths) await writeFile(`${session.cwd}/${path}`, `Opened ${path}`);
await writeFile(
`${session.cwd}/src/index.ts`,
Array.from({ length: 30 }, (_, i) => `export const line${i + 1} = ${i + 1};`).join("\n"),
);
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
await expect(message.getByRole("link", { name: ".gitignore", exact: true })).toBeVisible();
await expect(message.getByRole("link", { name: "before/after", exact: true })).toHaveCount(0);
await expect(message.getByRole("link", { name: "word.word", exact: true })).toHaveCount(0);
for (const path of paths) {
await openInlineRegressionFile(page, path);
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toContainText(
`Opened ${path}`,
);
}
await openInlineRegressionFile(page, "src/index.ts#L10-L20");
await expect(page.getByLabel("Line 20, column 26")).toBeVisible();
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-content")
.focus();
await page.keyboard.press("ControlOrMeta+c");
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe(
Array.from({ length: 11 }, (_, i) => `export const line${i + 10} = ${i + 10};`).join("\n"),
);
await expect(
page
.getByTestId("file-source-editor")
.filter({ visible: true })
.locator(".cm-selectionBackground"),
).not.toHaveCount(0);
await testInfo.attach("inline-path-line-selection", {
body: await page.screenshot(),
contentType: "image/png",
});
} finally {
await session.cleanup();
}
});
async function openInlineRegressionFile(page: Page, path: string) {
await page.getByRole("button", { name: "Inline path regression", exact: true }).click();
await page
.getByTestId("assistant-message")
.getByRole("link", { name: path, exact: true })
.click();
}
@@ -1,6 +1,8 @@
import { fromMarkdown } from "mdast-util-from-markdown";
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
import { QueryClient } from "@tanstack/react-query";
@@ -112,7 +114,7 @@ it.each([
"src/file.ts:10-20",
"src/file.ts(10,2)",
])("recognizes inline paths through the file resolver: %s", (path) => {
const prepared = prepareMarkdownSource(`Open \`${path}\`.`);
const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/repo");
expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true);
expect(prepared.linkSource(path, "inline-code")).toEqual({
href: path,
@@ -121,6 +123,106 @@ it.each([
});
});
it("preserves stable inline-code file classification through the library parser", async () => {
const paths = [
"src/app/[id].tsx",
"src/app/(tabs)/index.tsx",
"src/a$b.ts",
".gitignore",
"src/index.ts#L10-L20",
"src/index.ts:10-20",
"src/index.ts:20-10",
"src/index.ts#L0",
"src/index.ts?raw",
"before/after",
"word.word",
"README.md",
"./scripts/run",
"src/file.ts(10,2)",
"src/file.ts lines 10-20",
"/tmp/[route].tsx",
"file:///tmp/file.ts#L10-L20",
];
const source = paths.map((path) => `\`${path}\``).join(" and ");
const prepared = prepareMarkdownSource(source, "/repo");
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const links: string[] = [];
function collect(node: ASTNode) {
if (node.type === "Link") links.push(String(node.attributes?.url));
node.children?.forEach(collect);
}
collect(tree);
expect(links).toEqual([
"src/app/[id].tsx",
"src/app/(tabs)/index.tsx",
"src/a$b.ts",
".gitignore",
"src/index.ts#L10-L20",
"src/index.ts:10-20",
"README.md",
"./scripts/run",
"src/file.ts(10,2)",
"/tmp/[route].tsx",
"file:///tmp/file.ts#L10-L20",
]);
expect(
classifyAssistantFileLink("src/index.ts#L10-L20", { workspaceRoot: "/repo" }),
).toMatchObject({ target: { path: "/repo/src/index.ts", lineStart: 10, lineEnd: 20 } });
});
it("lets the engine own code boundaries while classifying candidate values", async () => {
const source = [
"```ts\n`src/fenced.ts`\n```",
"`` src/app/[id].tsx `` and ` src/spaced.ts `",
"``src/a`b.ts`` and `src/after.ts`",
"\\`src/escaped.ts` and `src/final.ts`",
"`\nsrc/newline.ts\n`",
"[`src/explicit.ts`](https://example.com)",
].join("\n\n");
const prepared = prepareMarkdownSource(source, "/repo");
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const recognized: string[] = [];
function collect(node: ASTNode) {
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
recognized.push(String(node.attributes.url));
}
node.children?.forEach(collect);
}
collect(tree);
expect(recognized).toEqual([
"src/app/[id].tsx",
" src/spaced.ts ",
"src/a`b.ts",
"src/after.ts",
"src/escaped.ts",
"src/final.ts",
"src/newline.ts",
]);
});
it("requires workspace context for relative inline paths and escapes literal regex characters", () => {
const source = "`src/app/[id].tsx` and `src/a+b.ts` and `README.md` and `/tmp/file.ts`";
const withoutWorkspace = prepareMarkdownSource(source).linkRecognition.inlineCode;
expect(withoutWorkspace.test("src/app/[id].tsx")).toBe(false);
expect(withoutWorkspace.test("README.md")).toBe(false);
expect(withoutWorkspace.test("/tmp/file.ts")).toBe(true);
const prepared = prepareMarkdownSource(source, "/repo");
const native = toNativeLinkRecognition(prepared.linkRecognition);
const pattern = new RegExp(native.inlineCode.pattern);
expect(pattern.test("src/app/[id].tsx")).toBe(true);
expect(pattern.test("src/a+b.ts")).toBe(true);
expect(pattern.test("src/app/i.tsx")).toBe(false);
expect(pattern.test("src/ab.ts")).toBe(false);
});
it("recognizes bare Unicode filenames and preserves inline-code URLs", () => {
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.");
expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([
@@ -489,7 +591,7 @@ it.each([
"×/÷",
"2024/10/16",
])("leaves non-path tokens unlinked in prose and inline code: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then continue.`);
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then \`${token}\`.`, "/repo");
expect([...`Value ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
expect(linkRecognition.inlineCode.test(token)).toBe(false);
});
@@ -514,12 +616,11 @@ it.each([
"123.ts",
"FILE.TS",
"https://example.com/path",
])("recognizes complete paths and URLs in prose and inline code: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then continue.`);
])("recognizes complete paths and URLs in prose: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then \`${token}\`.`, "/repo");
expect(
[...`Open ${token} then continue.`.matchAll(linkRecognition.text)].map(([text]) => text),
).toEqual([token]);
expect(linkRecognition.inlineCode.test(token)).toBe(true);
});
it.each([
@@ -530,10 +631,13 @@ it.each([
"packages/app",
"123/file",
"src/123",
])("requires stronger path intent in prose while preserving inline code: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(`Compare ${token}, then continue.`);
])("leaves extensionless relative paths unlinked in prose and inline code: %s", (token) => {
const { linkRecognition } = prepareMarkdownSource(
`Compare ${token}, then \`${token}\`.`,
"/repo",
);
expect([...`Compare ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
expect(linkRecognition.inlineCode.test(token)).toBe(true);
expect(linkRecognition.inlineCode.test(token)).toBe(false);
});
it("keeps prose and inline-code recognition enabled through the native regex adapter", () => {
+34 -7
View File
@@ -1,3 +1,4 @@
import { classifyAssistantFileLink } from "@/assistant-file-links";
import { ASSISTANT_FILE_EXTENSIONS } from "@/assistant-file-links/extensions";
import { normalizeMarkdownHtml } from "./html";
import { gfmTable } from "micromark-extension-gfm-table";
@@ -22,7 +23,6 @@ const SEPARATED_PATH = String.raw`${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})+`;
const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
// Bare prose paths need a known extension, a line suffix, or at least two separators.
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
const INLINE_CODE_PATH = String.raw`${PATH_PREFIX}?${HAS_PATH_LETTER}${SEPARATED_PATH}`;
function createFilePattern(path: string): RegExp {
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
@@ -33,20 +33,46 @@ function createFilePattern(path: string): RegExp {
}
const FILE_PATTERN = createFilePattern(PROSE_PATH);
const INLINE_CODE_FILE_PATTERN = createFilePattern(INLINE_CODE_PATH);
function linkMetadata() {
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
// The engine accepts regexes on both platforms. Classify complete code spans
// first, then pass literal matches rather than maintaining a second path grammar.
const alternatives = [...inlineCodePaths].map((path) =>
path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
);
return {
linkRecognition: {
text: FILE_PATTERN,
inlineCode: new RegExp(
String.raw`^(?:${INLINE_CODE_FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
),
},
linkSource: markdownLinkSource,
};
}
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> {
const paths = new Set<string>();
const previousEnds = new Map<number, number>();
// Collect candidates between consecutive runs of equal length. The engine owns
// Markdown context (escapes, fences, authored links); extra candidates are harmless
// because recognition only applies to complete Code nodes. This avoids an AST
// parse on every streaming reveal, including updates inside long code fences.
for (const match of source.matchAll(/`+/g)) {
const length = match[0].length;
const previousEnd = previousEnds.get(length);
previousEnds.set(length, match.index + length);
if (previousEnd === undefined) continue;
let value = source.slice(previousEnd, match.index).replace(/\r\n?|\n/g, " ");
// CommonMark code spans replace line endings and strip one surrounding space.
if (value.startsWith(" ") && value.endsWith(" ") && /[^ ]/.test(value)) {
value = value.slice(1, -1);
}
const classification = classifyAssistantFileLink(value, { workspaceRoot });
if (classification && classification.kind !== "external") paths.add(value);
}
return paths;
}
export function markdownLinkSource(
url: string,
sourceType?: "markdown" | "text" | "inline-code",
@@ -90,6 +116,7 @@ export function prepareMarkdownSource(
extensions: [gfmTable()],
mdastExtensions: [gfmTableFromMarkdown()],
}));
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
const edits: { start: number; end: number; text: string }[] = [];
if (
!source.includes("![") &&
@@ -97,7 +124,7 @@ export function prepareMarkdownSource(
!source.includes("&") &&
!(workspaceRoot && source.includes("]("))
) {
return { images, ...linkMetadata(), render: () => source };
return { images, ...metadata, render: () => source };
}
const definitions = new Map<string, { url: string; title?: string | null }>();
@@ -206,7 +233,7 @@ export function prepareMarkdownSource(
visit(getTree());
return {
images,
...linkMetadata(),
...metadata,
render(
urls: ReadonlyMap<string, string> = new Map(),
unavailableImages: ReadonlySet<string> = new Set(),