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>
This commit is contained in:
paseo-bot[bot]
2026-10-17 12:55:19 +02:00
committed by GitHub
parent 4a32f95848
commit 06cdc5a0b8
4 changed files with 67 additions and 7 deletions
+10 -2
View File
@@ -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 });
+14 -5
View File
@@ -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<AssistantHrefParseOptions, "decodeHref"> = {},
options: Pick<AssistantHrefParseOptions, "decodeHref" | "allowExtensionlessNestedPaths"> = {},
): {
path: string;
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
@@ -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<AssistantHrefParseOptions, "allowExtensionlessNestedPaths"> = {},
): 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);
@@ -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 {
@@ -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" } };