From a9d5502fad08b5a93f36d266b706500ae2e82891 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 15 Mar 2026 13:50:52 +0700 Subject: [PATCH] fix(app): route assistant file links to workspace files --- .../app/src/components/agent-stream-view.tsx | 100 +------- packages/app/src/components/message.tsx | 8 +- packages/app/src/utils/inline-path.test.ts | 124 +++++++++- packages/app/src/utils/inline-path.ts | 232 ++++++++++++++++-- 4 files changed, 347 insertions(+), 117 deletions(-) diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index e24b26c3f..2cbea0ede 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -69,6 +69,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { getMarkdownListMarker } from "@/utils/markdown-list"; import { buildHostWorkspaceFileRoute } from "@/utils/host-routes"; +import { normalizeInlinePathTarget } from "@/utils/inline-path"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isToolSequenceItem = (item?: StreamItem) => @@ -155,7 +156,7 @@ export const AgentStreamView = forwardRef ); @@ -656,102 +658,6 @@ export const AgentStreamView = forwardRef 0 ? dir : "." }; - } - - const lastSlash = normalized.lastIndexOf("/"); - const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : "."; - - return { - directory: directory.length > 0 ? directory : ".", - file: normalized, - }; -} - -function normalizePathInput(value: string | undefined): string | null { - if (!value) { - return null; - } - - const trimmed = value - .trim() - .replace(/^['"`]/, "") - .replace(/['"`]$/, ""); - if (!trimmed) { - return null; - } - - return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/"); -} - -function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null { - const normalizedCwd = normalizePathInput(cwd); - if ( - !normalizedCwd || - !isAbsolutePath(pathValue) || - !isAbsolutePath(normalizedCwd) - ) { - return null; - } - - const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/"; - const comparePath = normalizePathForCompare(pathValue); - const compareCwd = normalizePathForCompare(normalizedCwdBase); - const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`; - const comparePrefix = normalizePathForCompare(prefix); - - if (comparePath === compareCwd) { - return "."; - } - - if (comparePath.startsWith(comparePrefix)) { - return pathValue.slice(prefix.length) || "."; - } - - return null; -} - -function normalizePathForCompare(value: string): string { - return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value; -} - -function isAbsolutePath(value: string): boolean { - return value.startsWith("/") || /^[A-Za-z]:\//.test(value); -} - function WorkingIndicator() { const dotOne = useSharedValue(0); const dotTwo = useSharedValue(0); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 6dbf5b1c6..d594cc6d7 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -72,7 +72,7 @@ import { } from "@/utils/tool-call-display"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { - parseFileProtocolUrl, + parseAssistantFileLink, parseInlinePathToken, type InlinePathTarget, } from "@/utils/inline-path"; @@ -429,6 +429,7 @@ interface AssistantMessageProps { message: string; timestamp: number; onInlinePathPress?: (target: InlinePathTarget) => void; + workspaceRoot?: string; disableOuterSpacing?: boolean; } @@ -744,6 +745,7 @@ export const AssistantMessage = memo(function AssistantMessage({ message, timestamp, onInlinePathPress, + workspaceRoot, disableOuterSpacing, }: AssistantMessageProps) { const { theme } = useUnistyles(); @@ -770,7 +772,7 @@ export const AssistantMessage = memo(function AssistantMessage({ const handleLinkPress = useCallback((url: string) => { const fileTarget = onInlinePathPress - ? parseFileProtocolUrl(url) + ? parseAssistantFileLink(url, { workspaceRoot }) : null; if (fileTarget) { onInlinePathPress?.(fileTarget); @@ -781,7 +783,7 @@ export const AssistantMessage = memo(function AssistantMessage({ // react-native-markdown-display opens the link itself when this returns true. // We already handled it above, so return false to avoid duplicate opens. return false; - }, [onInlinePathPress]); + }, [onInlinePathPress, workspaceRoot]); const markdownRules = useMemo(() => { return { diff --git a/packages/app/src/utils/inline-path.test.ts b/packages/app/src/utils/inline-path.test.ts index 3766d8163..cab631240 100644 --- a/packages/app/src/utils/inline-path.test.ts +++ b/packages/app/src/utils/inline-path.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from "vitest"; -import { parseFileProtocolUrl, parseInlinePathToken } from "./inline-path"; +import { + normalizeInlinePathTarget, + parseAssistantFileLink, + parseFileProtocolUrl, + parseInlinePathToken, +} from "./inline-path"; describe("parseInlinePathToken", () => { - it("returns null for plain paths (no line)", () => { + it("returns null for plain paths without a line number", () => { expect(parseInlinePathToken("src/app.ts")).toBeNull(); expect(parseInlinePathToken("README.md")).toBeNull(); }); @@ -67,6 +72,119 @@ describe("parseFileProtocolUrl", () => { it("rejects non-file URLs and invalid ranges", () => { expect(parseFileProtocolUrl("https://example.com/test.ts#L10")).toBeNull(); - expect(parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12")).toBeNull(); + expect( + parseFileProtocolUrl("file:///Users/test/project/src/app.tsx#L20-L12") + ).toBeNull(); + }); +}); + +describe("parseAssistantFileLink", () => { + it("parses absolute POSIX hrefs inside the active workspace", () => { + expect( + parseAssistantFileLink("/Users/test/project/src/app.tsx#L33", { + workspaceRoot: "/Users/test/project", + }) + ).toEqual({ + raw: "/Users/test/project/src/app.tsx#L33", + path: "/Users/test/project/src/app.tsx", + lineStart: 33, + lineEnd: undefined, + }); + }); + + it("parses absolute Windows hrefs inside the active workspace", () => { + expect( + parseAssistantFileLink("C:/repo/src/app.tsx#L12-L20", { + workspaceRoot: "C:/repo", + }) + ).toEqual({ + raw: "C:/repo/src/app.tsx#L12-L20", + path: "C:/repo/src/app.tsx", + lineStart: 12, + lineEnd: 20, + }); + }); + + it("allows file URLs even when they are outside the workspace root", () => { + expect( + parseAssistantFileLink("file:///tmp/outside.txt", { + workspaceRoot: "/Users/test/project", + }) + ).toEqual({ + raw: "file:///tmp/outside.txt", + path: "/tmp/outside.txt", + lineStart: undefined, + lineEnd: undefined, + }); + }); + + it("rejects absolute hrefs outside the workspace root", () => { + expect( + parseAssistantFileLink("/tmp/outside.txt", { + workspaceRoot: "/Users/test/project", + }) + ).toBeNull(); + }); + + it("rejects external URLs", () => { + expect( + parseAssistantFileLink("https://example.com/Users/test/project/src/app.tsx") + ).toBeNull(); + }); + + it("rejects invalid line fragments", () => { + expect( + parseAssistantFileLink("/Users/test/project/src/app.tsx#L20-L12", { + workspaceRoot: "/Users/test/project", + }) + ).toBeNull(); + }); +}); + +describe("normalizeInlinePathTarget", () => { + it("keeps relative file paths as file targets", () => { + expect( + normalizeInlinePathTarget("packages/app/src/components/message.tsx") + ).toEqual({ + directory: "packages/app/src/components", + file: "packages/app/src/components/message.tsx", + }); + }); + + it("resolves absolute paths under cwd back to workspace-relative paths", () => { + expect( + normalizeInlinePathTarget( + "/Users/test/project/packages/app/src/components/message.tsx", + "/Users/test/project" + ) + ).toEqual({ + directory: "packages/app/src/components", + file: "packages/app/src/components/message.tsx", + }); + }); + + it("keeps absolute paths outside cwd as absolute file targets", () => { + expect( + normalizeInlinePathTarget("/tmp/message.tsx", "/Users/test/project") + ).toEqual({ + directory: "/tmp", + file: "/tmp/message.tsx", + }); + }); + + it("treats cwd itself as the workspace root directory", () => { + expect( + normalizeInlinePathTarget("/Users/test/project", "/Users/test/project") + ).toEqual({ + directory: ".", + }); + }); + + it("keeps trailing-slash paths as directories", () => { + expect( + normalizeInlinePathTarget("/Users/test/project/packages/app/", "/Users/test/project") + ).toEqual({ + directory: "packages/app", + }); }); }); diff --git a/packages/app/src/utils/inline-path.ts b/packages/app/src/utils/inline-path.ts index c78eefded..d11f1c19f 100644 --- a/packages/app/src/utils/inline-path.ts +++ b/packages/app/src/utils/inline-path.ts @@ -6,6 +6,16 @@ export interface InlinePathTarget { } const FILE_PROTOCOL = "file:"; +const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:-L?([0-9]+))?$/i; + +export interface AssistantHrefParseOptions { + workspaceRoot?: string; +} + +export interface NormalizedInlinePathTarget { + directory: string; + file?: string; +} function normalizePathToken(value: string): string | null { const trimmed = value @@ -20,6 +30,27 @@ function normalizePathToken(value: string): string | null { return trimmed.replace(/\\/g, "/"); } +function parseLineFragment(value: string): Pick | null { + const rawFragment = value.startsWith("#") ? value.slice(1) : value; + if (!rawFragment) { + return { lineStart: undefined, lineEnd: undefined }; + } + + const lineMatch = rawFragment.match(INLINE_LINE_FRAGMENT); + const lineStart = lineMatch?.[1] ? parseInt(lineMatch[1], 10) : undefined; + const lineEnd = lineMatch?.[2] ? parseInt(lineMatch[2], 10) : undefined; + + if ( + (lineStart !== undefined && (!Number.isFinite(lineStart) || lineStart <= 0)) || + (lineEnd !== undefined && (!Number.isFinite(lineEnd) || lineEnd <= 0)) || + (lineStart !== undefined && lineEnd !== undefined && lineEnd < lineStart) + ) { + return null; + } + + return { lineStart, lineEnd }; +} + /** * Strict VSCode-style markers only. * @@ -103,29 +134,151 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null { return null; } - const rawFragment = parsedUrl.hash.startsWith("#") - ? parsedUrl.hash.slice(1) - : parsedUrl.hash; - const lineMatch = rawFragment.match(/^L([0-9]+)(?:-L?([0-9]+))?$/i); - const lineStart = lineMatch?.[1] ? parseInt(lineMatch[1], 10) : undefined; - const lineEnd = lineMatch?.[2] ? parseInt(lineMatch[2], 10) : undefined; - - if ( - (lineStart !== undefined && (!Number.isFinite(lineStart) || lineStart <= 0)) || - (lineEnd !== undefined && (!Number.isFinite(lineEnd) || lineEnd <= 0)) || - (lineStart !== undefined && lineEnd !== undefined && lineEnd < lineStart) - ) { + const lines = parseLineFragment(parsedUrl.hash); + if (!lines) { return null; } return { raw: value, path: normalizedPath, - lineStart, - lineEnd, + ...lines, }; } +export function parseAssistantFileLink( + value: string, + options: AssistantHrefParseOptions = {} +): InlinePathTarget | null { + const fileUrlTarget = parseFileProtocolUrl(value); + if (fileUrlTarget) { + return fileUrlTarget; + } + + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.includes("://")) { + return null; + } + + const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/); + if (windowsPathMatch) { + const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? ""); + if (!normalizedPath || !isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) { + return null; + } + + const lines = parseLineFragment(windowsPathMatch[2] ?? ""); + if (!lines) { + return null; + } + + return { + raw: value, + path: normalizedPath, + ...lines, + }; + } + + if (!trimmed.startsWith("/")) { + return null; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(trimmed, "http://paseo.invalid"); + } catch { + return null; + } + + const normalizedPath = normalizePathToken(decodeURIComponent(parsedUrl.pathname)); + if (!normalizedPath || !normalizedPath.startsWith("/")) { + return null; + } + + if (!isAllowedAbsolutePath(normalizedPath, options.workspaceRoot)) { + return null; + } + + const lines = parseLineFragment(parsedUrl.hash); + if (!lines) { + return null; + } + + return { + raw: value, + path: normalizedPath, + ...lines, + }; +} + +export function normalizeInlinePathTarget( + rawPath: string, + cwd?: string +): NormalizedInlinePathTarget | null { + if (!rawPath) { + return null; + } + + const normalizedInput = normalizePathInput(rawPath); + if (!normalizedInput) { + return null; + } + + let normalized = normalizedInput; + const cwdRelative = resolvePathAgainstCwd(normalized, cwd); + if (cwdRelative) { + normalized = cwdRelative; + } + + if (normalized.startsWith("./")) { + normalized = normalized.slice(2) || "."; + } + + if (!normalized.length) { + normalized = "."; + } + + if (normalized === ".") { + return { directory: "." }; + } + + if (normalized.endsWith("/")) { + const dir = normalized.replace(/\/+$/, ""); + return { directory: dir.length > 0 ? dir : "." }; + } + + const lastSlash = normalized.lastIndexOf("/"); + const directory = lastSlash >= 0 ? normalized.slice(0, lastSlash) : "."; + + return { + directory: directory.length > 0 ? directory : ".", + file: normalized, + }; +} + +function isAllowedAbsolutePath(pathValue: string, workspaceRoot?: string): boolean { + const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot); + if (!normalizedWorkspaceRoot) { + return true; + } + + const comparePath = normalizePathForCompare(pathValue); + const compareWorkspaceRoot = normalizePathForCompare( + normalizedWorkspaceRoot.replace(/\/+$/, "") || "/" + ); + const comparePrefix = + compareWorkspaceRoot === "/" ? "/" : `${compareWorkspaceRoot}/`; + + return ( + comparePath === compareWorkspaceRoot || + comparePath.startsWith(comparePrefix) + ); +} + function normalizeFileUrlPath(pathname: string): string | null { if (!pathname) { return null; @@ -142,3 +295,54 @@ function normalizeFileUrlPath(pathname: string): string | null { return decoded; } + +function normalizePathInput(value: string | undefined): string | null { + if (!value) { + return null; + } + + const trimmed = value + .trim() + .replace(/^['"`]/, "") + .replace(/['"`]$/, ""); + if (!trimmed) { + return null; + } + + return trimmed.replace(/\\/g, "/").replace(/\/{2,}/g, "/"); +} + +function resolvePathAgainstCwd(pathValue: string, cwd?: string): string | null { + const normalizedCwd = normalizePathInput(cwd); + if ( + !normalizedCwd || + !isAbsolutePath(pathValue) || + !isAbsolutePath(normalizedCwd) + ) { + return null; + } + + const normalizedCwdBase = normalizedCwd.replace(/\/+$/, "") || "/"; + const comparePath = normalizePathForCompare(pathValue); + const compareCwd = normalizePathForCompare(normalizedCwdBase); + const prefix = normalizedCwdBase === "/" ? "/" : `${normalizedCwdBase}/`; + const comparePrefix = normalizePathForCompare(prefix); + + if (comparePath === compareCwd) { + return "."; + } + + if (comparePath.startsWith(comparePrefix)) { + return pathValue.slice(prefix.length) || "."; + } + + return null; +} + +function normalizePathForCompare(value: string): string { + return /^[A-Za-z]:/.test(value) ? value.toLowerCase() : value; +} + +function isAbsolutePath(value: string): boolean { + return value.startsWith("/") || /^[A-Za-z]:\//.test(value); +}