diff --git a/packages/app/e2e/browser/plan-card-markdown.spec.ts b/packages/app/e2e/browser/plan-card-markdown.spec.ts index 5dcf2a2cec..13428ce445 100644 --- a/packages/app/e2e/browser/plan-card-markdown.spec.ts +++ b/packages/app/e2e/browser/plan-card-markdown.spec.ts @@ -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(); +} diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 8791cf40de..c8a7375737 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -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", () => { diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index 9fda25b728..9977ccfef3 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -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) { + // 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 { + const paths = new Set(); + const previousEnds = new Map(); + // 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(); @@ -206,7 +233,7 @@ export function prepareMarkdownSource( visit(getTree()); return { images, - ...linkMetadata(), + ...metadata, render( urls: ReadonlyMap = new Map(), unavailableImages: ReadonlySet = new Set(),