From 06cdc5a0b83a2f2f6875613441a86aaa96e59c15 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:55:19 +0200 Subject: [PATCH] fix(app): open nested Markdown folder links without dot prefixes (#7210) Explicit Markdown hrefs were rejected by the filename heuristic used for inferred text. Let authored nested paths resolve without a recognized extension while preserving inline-code, automatic-link, and domain handling. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/e2e/browser/folder-links.spec.ts | 12 +++++- .../app/src/assistant-file-links/parse.ts | 19 ++++++--- .../src/assistant-file-links/resolver.test.ts | 42 +++++++++++++++++++ .../app/src/assistant-file-links/resolver.ts | 1 + 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/browser/folder-links.spec.ts b/packages/app/e2e/browser/folder-links.spec.ts index 7e1bfac1ae..8c1339a8c6 100644 --- a/packages/app/e2e/browser/folder-links.spec.ts +++ b/packages/app/e2e/browser/folder-links.spec.ts @@ -16,13 +16,21 @@ async function expectFolderContents(page: Page) { ).toBeVisible(); } -for (const folder of ["docs/guides", "docs/guides/", "docs/reports.v1", ".github/workflows"]) { +for (const folder of [ + "./docs/guides", + "./docs/guides/", + "./docs/reports.v1", + "./.github/workflows", + "docs/guides", + "docs/guides/", + "docs/指南", +]) { test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => { const session = await seedMockAgentWorkspace({ repoPrefix: "folder-link-repro-", title: "Folder link reproduction", initialPrompt: "Show the folder link", - featureValues: { mockAssistantResponse: `[Open folder](./${folder})` }, + featureValues: { mockAssistantResponse: `[Open folder](${folder})` }, }); try { await mkdir(path.join(session.cwd, folder), { recursive: true }); diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts index 1904c11907..c195a277cf 100644 --- a/packages/app/src/assistant-file-links/parse.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -30,6 +30,8 @@ export interface AssistantHrefParseOptions { * send a real file named `my%20file.md` to `my file.md`. */ decodeHref?: boolean; + /** Explicit Markdown links can name nested folders without a filename extension. */ + allowExtensionlessNestedPaths?: boolean; } export type AssistantFileLinkClassification = @@ -278,6 +280,7 @@ export function parseAssistantFileLink( const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, { workspaceRoot: options.workspaceRoot, decodeHref: options.decodeHref, + allowExtensionlessNestedPaths: options.allowExtensionlessNestedPaths, }); if (relativeTarget) { return relativeTarget; @@ -411,7 +414,7 @@ function parseWorkspaceRelativeFileLink( function parseLocalPathParts( value: string, - options: Pick = {}, + options: Pick = {}, ): { path: string; lines: Pick; @@ -437,7 +440,7 @@ function parseLocalPathParts( if (options.decodeHref) { pathPart = safeDecodeURIComponent(pathPart); } - if (!isPlausibleAssistantLocalPath(pathPart)) { + if (!isPlausibleAssistantLocalPath(pathPart, options)) { return null; } return { @@ -458,7 +461,7 @@ function parseLocalPathParts( decodedBeforeHash = safeDecodeURIComponent(beforeHash); } const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash; - if (!isPlausibleAssistantLocalPath(plausiblePath)) { + if (!isPlausibleAssistantLocalPath(plausiblePath, options)) { return null; } @@ -583,7 +586,10 @@ function getHeuristicLocalPath(value: string): string | null { return beforeHash; } -function isPlausibleAssistantLocalPath(pathValue: string): boolean { +function isPlausibleAssistantLocalPath( + pathValue: string, + options: Pick = {}, +): boolean { const normalized = normalizePathToken(pathValue); if (!normalized) { return false; @@ -607,7 +613,10 @@ function isPlausibleAssistantLocalPath(pathValue: string): boolean { if (segments.length > 1) { const lastSegment = segments[segments.length - 1]; - return !isDomainLikePathSegment(firstSegment) && isPlausibleAssistantFileName(lastSegment); + return ( + !isDomainLikePathSegment(firstSegment) && + (options.allowExtensionlessNestedPaths === true || isPlausibleAssistantFileName(lastSegment)) + ); } return isPlausibleAssistantFileName(firstSegment); diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts index 384c3b6857..854c358627 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -18,6 +18,48 @@ const CONTEXT: AssistantFileLinkContext = { workspaceRoot: "/Users/test/project", }; +it.each(["docs/guides", "docs/guides/", "docs/%E6%8C%87%E5%8D%97"])( + "resolves an explicit extensionless folder href: %s", + (href) => { + expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({ + kind: "resolved", + value: { + kind: "file", + target: { path: `${CONTEXT.workspaceRoot}/${decodeURIComponent(href).replace(/\/$/, "")}` }, + }, + }); + }, +); + +it.each(["docs/guides", "origin/main"])("leaves extensionless inline code alone: %s", (text) => { + expect(classifyForResolution({ href: text, text, sourceType: "inline-code" }, CONTEXT)).toEqual({ + kind: "resolved", + value: { kind: "ignored" }, + }); +}); + +it.each([{ markup: "linkify" }, { sourceInfo: "auto" }])( + "keeps auto-recognized extensionless text ignored: %j", + (origin) => { + expect( + classifyForResolution({ href: "docs/guides", text: "docs/guides", ...origin }, CONTEXT), + ).toEqual({ + kind: "resolved", + value: { kind: "ignored" }, + }); + }, +); + +it.each(["example.com/guides", "docs/guides?view=all"])( + "does not turn URL-like hrefs into workspace paths: %s", + (href) => { + expect(classifyForResolution({ href }, CONTEXT)).toEqual({ + kind: "resolved", + value: { kind: "ignored" }, + }); + }, +); + it("opens the existing non-ASCII file named by a rendered markdown link", async () => { const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-")); try { diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts index 774d39278b..59057ba0ca 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -117,6 +117,7 @@ export function classifyForResolution( const classification = classifyAssistantFileLink(token, { workspaceRoot: context.workspaceRoot, decodeHref: tokenInfo.fromHref, + allowExtensionlessNestedPaths: tokenInfo.fromHref && !isLinkifiedSource(source), }); if (!classification) { return { kind: "resolved", value: { kind: "ignored" } };