mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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(")
|
||||
) {
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user