From 09b3dcf9a1885da62ecd814ddd49dd2db71e58f3 Mon Sep 17 00:00:00 2001 From: Moy Date: Fri, 9 Oct 2026 11:50:27 +0800 Subject: [PATCH] fix(app): open folders from inline path links in the file explorer Clicking a folder path in an assistant message opened a file tab that failed with "Requested path is not a file". Extension-less workspace-relative paths without a line number are now probed with a directory listing before opening: directories open the Files explorer navigated to that folder, everything else opens as a file like before. Lexical directories (trailing slash, workspace root) now navigate the explorer to the folder instead of only preloading its listing. --- .../src/agent-stream/inline-path-open.test.ts | 52 +++++++++++++++ .../app/src/agent-stream/inline-path-open.ts | 36 +++++++++++ packages/app/src/agent-stream/view.tsx | 64 +++++++++++++------ 3 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 packages/app/src/agent-stream/inline-path-open.test.ts create mode 100644 packages/app/src/agent-stream/inline-path-open.ts diff --git a/packages/app/src/agent-stream/inline-path-open.test.ts b/packages/app/src/agent-stream/inline-path-open.test.ts new file mode 100644 index 0000000000..d2a80a82b7 --- /dev/null +++ b/packages/app/src/agent-stream/inline-path-open.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { planInlinePathOpen } from "./inline-path-open"; + +describe("planInlinePathOpen", () => { + it("opens paths with a line number directly as files", () => { + expect( + planInlinePathOpen({ + file: "src/moys-asr-workflow", + lineStart: 12, + workspaceRoot: "/repo", + }), + ).toEqual({ kind: "file" }); + }); + + it("opens paths with a file extension directly as files", () => { + expect( + planInlinePathOpen({ file: "src/components/message.tsx", workspaceRoot: "/repo" }), + ).toEqual({ kind: "file" }); + expect(planInlinePathOpen({ file: "README.md", workspaceRoot: "/repo" })).toEqual({ + kind: "file", + }); + }); + + it("treats a missing file segment as a directory", () => { + expect(planInlinePathOpen({ file: undefined, workspaceRoot: "/repo" })).toEqual({ + kind: "directory", + directoryPath: ".", + }); + }); + + it("probes extension-less workspace-relative paths", () => { + expect( + planInlinePathOpen({ file: "packages/app/moys-asr-workflow", workspaceRoot: "/repo" }), + ).toEqual({ kind: "probe", directoryPath: "packages/app/moys-asr-workflow" }); + expect(planInlinePathOpen({ file: "Makefile", workspaceRoot: "/repo" })).toEqual({ + kind: "probe", + directoryPath: "Makefile", + }); + }); + + it("opens extension-less absolute paths directly as files", () => { + expect(planInlinePathOpen({ file: "C:/Tools/some-folder", workspaceRoot: "C:/repo" })).toEqual({ + kind: "file", + }); + }); + + it("opens extension-less paths directly as files when there is no workspace root", () => { + expect(planInlinePathOpen({ file: "some-folder", workspaceRoot: " " })).toEqual({ + kind: "file", + }); + }); +}); diff --git a/packages/app/src/agent-stream/inline-path-open.ts b/packages/app/src/agent-stream/inline-path-open.ts new file mode 100644 index 0000000000..80793cf061 --- /dev/null +++ b/packages/app/src/agent-stream/inline-path-open.ts @@ -0,0 +1,36 @@ +import { isAbsolutePath } from "@/utils/path"; + +export type InlinePathOpenPlan = + | { kind: "file" } + | { kind: "directory"; directoryPath: string } + | { kind: "probe"; directoryPath: string }; + +/** + * Decides how an inline path press opens. Extension-less workspace-relative paths without a + * line number cannot be told apart from folders lexically ("moys-asr-workflow" is a folder, + * "Makefile" is a file), so they come back as "probe" and the caller asks the daemon which + * one it is before opening a file tab that would fail with "Requested path is not a file". + */ +export function planInlinePathOpen(input: { + file?: string; + lineStart?: number; + workspaceRoot: string; +}): InlinePathOpenPlan { + const { file, lineStart, workspaceRoot } = input; + if (!file) { + // Lexical directory: a trailing slash, ".", or the workspace root itself. + return { kind: "directory", directoryPath: "." }; + } + if (lineStart !== undefined || hasFileExtension(file)) { + return { kind: "file" }; + } + if (isAbsolutePath(file) || !workspaceRoot.trim()) { + return { kind: "file" }; + } + return { kind: "probe", directoryPath: file }; +} + +function hasFileExtension(value: string): boolean { + const lastSegment = value.split("/").pop() ?? ""; + return /\.[A-Za-z0-9]+$/.test(lastSegment); +} diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 09b02cda18..83123c1b77 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -91,6 +91,7 @@ import { AssistantFileLinkResolverProvider, normalizeInlinePathTarget, } from "@/assistant-file-links"; +import { planInlinePathOpen } from "./inline-path-open"; import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, @@ -437,7 +438,10 @@ const AgentStreamViewComponent = forwardRef { + if (!normalized.file) { + return; + } const location = normalizeWorkspaceFileLocation({ path: normalized.file, lineStart: target.lineStart, @@ -462,27 +466,51 @@ const AgentStreamViewComponent = forwardRef { + void requestDirectoryListing(directoryPath); + + openExplorerSidebarView({ + isCompact: isMobile, + workspaceKey: buildWorkspaceTabPersistenceKey({ + serverId: resolvedServerId, + workspaceId: context.workspaceId ?? "", + }), + checkout: { + serverId: resolvedServerId, + cwd: context.cwd, + isGit: context.projectPlacement?.checkout?.isGit ?? true, + }, + view: "files", + }); + }; + + const plan = planInlinePathOpen({ + file: normalized.file, + lineStart: target.lineStart, + workspaceRoot, + }); + + if (plan.kind === "file") { + openFileLocation(); return; } - void requestDirectoryListing(normalized.directory, { - recordHistory: false, - setCurrentPath: false, - }); + if (plan.kind === "directory") { + openFilesExplorerAt(normalized.directory || "."); + return; + } - openExplorerSidebarView({ - isCompact: isMobile, - workspaceKey: buildWorkspaceTabPersistenceKey({ - serverId: resolvedServerId, - workspaceId: context.workspaceId ?? "", - }), - checkout: { - serverId: resolvedServerId, - cwd: context.cwd, - isGit: context.projectPlacement?.checkout?.isGit ?? true, - }, - view: "files", - }); + if (!client) { + openFileLocation(); + return; + } + + void client.listDirectory(workspaceRoot, plan.directoryPath).then( + () => openFilesExplorerAt(plan.directoryPath), + () => openFileLocation(), + ); }, );