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);
+}