diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx
new file mode 100644
index 0000000000..28f8b5fb53
--- /dev/null
+++ b/apps/web/src/components/ChatMarkdown.browser.tsx
@@ -0,0 +1,76 @@
+import "../index.css";
+
+import { page } from "vitest/browser";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { render } from "vitest-browser-react";
+
+const { openInPreferredEditorMock, readLocalApiMock } = vi.hoisted(() => ({
+ openInPreferredEditorMock: vi.fn(async () => "vscode"),
+ readLocalApiMock: vi.fn(() => ({
+ server: { getConfig: vi.fn(async () => ({ availableEditors: ["vscode"] })) },
+ shell: { openInEditor: vi.fn(async () => undefined) },
+ })),
+}));
+
+vi.mock("../editorPreferences", () => ({
+ openInPreferredEditor: openInPreferredEditorMock,
+}));
+
+vi.mock("../localApi", () => ({
+ readLocalApi: readLocalApiMock,
+}));
+
+import ChatMarkdown from "./ChatMarkdown";
+
+describe("ChatMarkdown", () => {
+ afterEach(() => {
+ openInPreferredEditorMock.mockClear();
+ readLocalApiMock.mockClear();
+ localStorage.clear();
+ document.body.innerHTML = "";
+ });
+
+ it("rewrites file uri hrefs into direct paths before rendering", async () => {
+ const filePath =
+ "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts";
+ const screen = await render(
+ ,
+ );
+
+ try {
+ const link = page.getByRole("link", { name: "PermissionRule.ts" });
+ await expect.element(link).toBeInTheDocument();
+ await expect.element(link).toHaveAttribute("href", filePath);
+
+ await link.click();
+
+ await vi.waitFor(() => {
+ expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), filePath);
+ });
+ } finally {
+ await screen.unmount();
+ }
+ });
+
+ it("keeps line anchors working after rewriting file uri hrefs", async () => {
+ const filePath =
+ "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts";
+ const screen = await render(
+ ,
+ );
+
+ try {
+ const link = page.getByRole("link", { name: "PermissionRule.ts:1" });
+ await expect.element(link).toBeInTheDocument();
+ await expect.element(link).toHaveAttribute("href", `${filePath}#L1`);
+
+ await link.click();
+
+ await vi.waitFor(() => {
+ expect(openInPreferredEditorMock).toHaveBeenCalledWith(expect.anything(), `${filePath}:1`);
+ });
+ } finally {
+ await screen.unmount();
+ }
+ });
+});
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index 11926cd95c..327e77e57c 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -15,13 +15,14 @@ import React, {
} from "react";
import type { Components } from "react-markdown";
import ReactMarkdown from "react-markdown";
+import { defaultUrlTransform } from "react-markdown";
import remarkGfm from "remark-gfm";
import { openInPreferredEditor } from "../editorPreferences";
import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
import { fnv1a32 } from "../lib/diffRendering";
import { LRUCache } from "../lib/lruCache";
import { useTheme } from "../hooks/useTheme";
-import { resolveMarkdownFileLinkTarget } from "../markdown-links";
+import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links";
import { readLocalApi } from "../localApi";
class CodeHighlightErrorBoundary extends React.Component<
@@ -238,6 +239,9 @@ function SuspenseShikiCodeBlock({
function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const diffThemeName = resolveDiffThemeName(resolvedTheme);
+ const markdownUrlTransform = useCallback((href: string) => {
+ return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
+ }, []);
const markdownComponents = useMemo(
() => ({
a({ node: _node, href, ...props }) {
@@ -290,7 +294,11 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
return (
-
+
{text}
diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts
index 5f0a0cd711..d3ca8bc99a 100644
--- a/apps/web/src/markdown-links.test.ts
+++ b/apps/web/src/markdown-links.test.ts
@@ -1,6 +1,20 @@
import { describe, expect, it } from "vitest";
-import { resolveMarkdownFileLinkTarget } from "./markdown-links";
+import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "./markdown-links";
+
+describe("rewriteMarkdownFileUriHref", () => {
+ it("rewrites file uri hrefs into direct path hrefs", () => {
+ expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe(
+ "/Users/julius/project/src/main.ts#L42",
+ );
+ });
+
+ it("preserves encoded octets so file paths are decoded only once later", () => {
+ expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/file%2520name.md")).toBe(
+ "/Users/julius/project/file%2520name.md",
+ );
+ });
+});
describe("resolveMarkdownFileLinkTarget", () => {
it("resolves absolute posix file paths", () => {
diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts
index aa5f0f1498..b5dcab0100 100644
--- a/apps/web/src/markdown-links.ts
+++ b/apps/web/src/markdown-links.ts
@@ -38,25 +38,36 @@ function stripSearchAndHash(value: string): { path: string; hash: string } {
return { path, hash: rawHash };
}
-function parseFileUrlHref(href: string): { path: string; hash: string } | null {
+function parseFileUrlHref(
+ href: string,
+ options?: { readonly decodePath?: boolean },
+): { path: string; hash: string } | null {
try {
const parsed = new URL(href);
if (parsed.protocol.toLowerCase() !== "file:") return null;
- const decodedPath = safeDecode(parsed.pathname);
- if (decodedPath.length === 0) return null;
+ const rawPath = parsed.pathname;
+ if (rawPath.length === 0) return null;
// Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs.
- const normalizedPath = /^\/[A-Za-z]:[\\/]/.test(decodedPath)
- ? decodedPath.slice(1)
- : decodedPath;
+ const normalizedPath = /^\/[A-Za-z]:[\\/]/.test(rawPath) ? rawPath.slice(1) : rawPath;
- return { path: normalizedPath, hash: parsed.hash };
+ return {
+ path: options?.decodePath === false ? normalizedPath : safeDecode(normalizedPath),
+ hash: parsed.hash,
+ };
} catch {
return null;
}
}
+export function rewriteMarkdownFileUriHref(href: string | undefined): string | null {
+ if (!href) return null;
+ const target = parseFileUrlHref(href.trim(), { decodePath: false });
+ if (!target) return null;
+ return `${target.path}${target.hash}`;
+}
+
function looksLikePosixFilesystemPath(path: string): boolean {
if (!path.startsWith("/")) return false;
if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;