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