diff --git a/src/features/messages/components/Markdown.test.tsx b/src/features/messages/components/Markdown.test.tsx
new file mode 100644
index 000000000..b96f29419
--- /dev/null
+++ b/src/features/messages/components/Markdown.test.tsx
@@ -0,0 +1,72 @@
+// @vitest-environment jsdom
+import { cleanup, createEvent, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { Markdown } from "./Markdown";
+
+describe("Markdown file-like href behavior", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ it("preserves default anchor navigation when no file opener is provided", () => {
+ render(
+ ,
+ );
+
+ const link = screen.getByText("setup").closest("a");
+ expect(link?.getAttribute("href")).toBe("./docs/setup.md");
+
+ const clickEvent = createEvent.click(link as Element, {
+ bubbles: true,
+ cancelable: true,
+ });
+ fireEvent(link as Element, clickEvent);
+ expect(clickEvent.defaultPrevented).toBe(false);
+ });
+
+ it("intercepts file-like href clicks when a file opener is provided", () => {
+ const onOpenFileLink = vi.fn();
+ render(
+ ,
+ );
+
+ const link = screen.getByText("setup").closest("a");
+ expect(link?.getAttribute("href")).toBe("./docs/setup.md");
+
+ const clickEvent = createEvent.click(link as Element, {
+ bubbles: true,
+ cancelable: true,
+ });
+ fireEvent(link as Element, clickEvent);
+ expect(clickEvent.defaultPrevented).toBe(true);
+ expect(onOpenFileLink).toHaveBeenCalledWith("./docs/setup.md");
+ });
+
+ it("does not intercept bare relative links even when a file opener is provided", () => {
+ const onOpenFileLink = vi.fn();
+ render(
+ ,
+ );
+
+ const link = screen.getByText("setup").closest("a");
+ expect(link?.getAttribute("href")).toBe("docs/setup.md");
+ const clickEvent = createEvent.click(link as Element, {
+ bubbles: true,
+ cancelable: true,
+ });
+ fireEvent(link as Element, clickEvent);
+ expect(clickEvent.defaultPrevented).toBe(false);
+ expect(onOpenFileLink).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/features/messages/components/Markdown.tsx b/src/features/messages/components/Markdown.tsx
index ec450531d..91bb49e15 100644
--- a/src/features/messages/components/Markdown.tsx
+++ b/src/features/messages/components/Markdown.tsx
@@ -180,6 +180,151 @@ function normalizeUrlLine(line: string) {
return withoutBullet;
}
+function safeDecodeURIComponent(value: string) {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return null;
+ }
+}
+
+function safeDecodeFileLink(url: string) {
+ try {
+ return decodeFileLink(url);
+ } catch {
+ return null;
+ }
+}
+
+const FILE_LINE_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
+const LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES = [
+ "/Users/",
+ "/home/",
+ "/tmp/",
+ "/var/",
+ "/opt/",
+ "/etc/",
+ "/private/",
+ "/Volumes/",
+ "/mnt/",
+ "/usr/",
+ "/workspace/",
+ "/workspaces/",
+ "/root/",
+ "/srv/",
+ "/data/",
+];
+
+function stripPathLineSuffix(value: string) {
+ return value.replace(FILE_LINE_SUFFIX_PATTERN, "");
+}
+
+function hasLikelyFileName(path: string) {
+ const normalizedPath = stripPathLineSuffix(path).replace(/[\\/]+$/, "");
+ const lastSegment = normalizedPath.split(/[\\/]/).pop() ?? "";
+ if (!lastSegment || lastSegment === "." || lastSegment === "..") {
+ return false;
+ }
+ if (lastSegment.startsWith(".") && lastSegment.length > 1) {
+ return true;
+ }
+ return lastSegment.includes(".");
+}
+
+function hasLikelyLocalAbsolutePrefix(path: string) {
+ const normalizedPath = path.replace(/\\/g, "/");
+ return LIKELY_LOCAL_ABSOLUTE_PATH_PREFIXES.some((prefix) =>
+ normalizedPath.startsWith(prefix),
+ );
+}
+
+function pathSegmentCount(path: string) {
+ return path.split("/").filter(Boolean).length;
+}
+
+function isLikelyFileHref(url: string) {
+ const trimmed = url.trim();
+ if (!trimmed) {
+ return false;
+ }
+ if (trimmed.startsWith("file://")) {
+ return true;
+ }
+ if (
+ trimmed.startsWith("http://") ||
+ trimmed.startsWith("https://") ||
+ trimmed.startsWith("mailto:")
+ ) {
+ return false;
+ }
+ if (trimmed.startsWith("thread://") || trimmed.startsWith("/thread/")) {
+ return false;
+ }
+ if (trimmed.startsWith("#")) {
+ return false;
+ }
+ if (/[?#]/.test(trimmed)) {
+ return false;
+ }
+ if (/^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.startsWith("\\\\")) {
+ return true;
+ }
+ if (trimmed.startsWith("/")) {
+ if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) {
+ return true;
+ }
+ if (hasLikelyFileName(trimmed)) {
+ return true;
+ }
+ return hasLikelyLocalAbsolutePrefix(trimmed) && pathSegmentCount(trimmed) >= 3;
+ }
+ if (FILE_LINE_SUFFIX_PATTERN.test(trimmed)) {
+ return true;
+ }
+ if (trimmed.startsWith("~/")) {
+ return true;
+ }
+ if (trimmed.startsWith("./") || trimmed.startsWith("../")) {
+ return FILE_LINE_SUFFIX_PATTERN.test(trimmed) || hasLikelyFileName(trimmed);
+ }
+ if (hasLikelyFileName(trimmed)) {
+ return pathSegmentCount(trimmed) >= 3;
+ }
+ return false;
+}
+
+function toPathFromFileUrl(url: string) {
+ if (!url.toLowerCase().startsWith("file://")) {
+ return null;
+ }
+
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol !== "file:") {
+ return null;
+ }
+
+ const decodedPath = safeDecodeURIComponent(parsed.pathname) ?? parsed.pathname;
+ let path = decodedPath;
+ if (parsed.host && parsed.host !== "localhost") {
+ const normalizedPath = decodedPath.startsWith("/")
+ ? decodedPath
+ : `/${decodedPath}`;
+ path = `//${parsed.host}${normalizedPath}`;
+ }
+ if (/^\/[A-Za-z]:\//.test(path)) {
+ path = path.slice(1);
+ }
+ return path;
+ } catch {
+ const manualPath = url.slice("file://".length).trim();
+ if (!manualPath) {
+ return null;
+ }
+ return safeDecodeURIComponent(manualPath) ?? manualPath;
+ }
+}
+
function extractUrlLines(value: string) {
const lines = value.split(/\r?\n/);
const urls = lines
@@ -474,9 +619,29 @@ export function Markdown({
}
return trimmed;
};
+ const resolveHrefFilePath = (url: string) => {
+ if (isLikelyFileHref(url)) {
+ const directPath = getLinkablePath(url);
+ if (directPath) {
+ return safeDecodeURIComponent(directPath) ?? directPath;
+ }
+ }
+ const decodedUrl = safeDecodeURIComponent(url);
+ if (decodedUrl && isLikelyFileHref(decodedUrl)) {
+ const decodedPath = getLinkablePath(decodedUrl);
+ if (decodedPath) {
+ return decodedPath;
+ }
+ }
+ const fileUrlPath = toPathFromFileUrl(url);
+ if (!fileUrlPath) {
+ return null;
+ }
+ return getLinkablePath(fileUrlPath);
+ };
const components: Components = {
a: ({ href, children }) => {
- const url = href ?? "";
+ const url = (href ?? "").trim();
const threadId = url.startsWith("thread://")
? url.slice("thread://".length).trim()
: url.startsWith("/thread/")
@@ -497,7 +662,20 @@ export function Markdown({
);
}
if (isFileLinkUrl(url)) {
- const path = decodeFileLink(url);
+ const path = safeDecodeFileLink(url);
+ if (!path) {
+ return (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ >
+ {children}
+
+ );
+ }
return (
);
}
+ const hrefFilePath = resolveHrefFilePath(url);
+ if (hrefFilePath) {
+ const clickHandler = onOpenFileLink
+ ? (event: React.MouseEvent) => handleFileLinkClick(event, hrefFilePath)
+ : undefined;
+ const contextMenuHandler = onOpenFileLinkMenu
+ ? (event: React.MouseEvent) => handleFileLinkContextMenu(event, hrefFilePath)
+ : undefined;
+ return (
+
+ {children}
+
+ );
+ }
const isExternal =
url.startsWith("http://") ||
url.startsWith("https://") ||
diff --git a/src/features/messages/components/Messages.test.tsx b/src/features/messages/components/Messages.test.tsx
index efd3990d2..14cd95398 100644
--- a/src/features/messages/components/Messages.test.tsx
+++ b/src/features/messages/components/Messages.test.tsx
@@ -234,6 +234,190 @@ describe("Messages", () => {
);
});
+ it("routes markdown href file paths through the file opener", () => {
+ const linkedPath =
+ "/Users/dimillian/Documents/Dev/CodexMonitor/src/features/messages/components/Markdown.tsx:244";
+ const items: ConversationItem[] = [
+ {
+ id: "msg-file-href-link",
+ kind: "message",
+ role: "assistant",
+ text: `Open [this file](${linkedPath})`,
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByText("this file"));
+ expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath);
+ });
+
+ it("routes absolute non-whitelisted file href paths through the file opener", () => {
+ const linkedPath = "/custom/project/src/App.tsx:12";
+ const items: ConversationItem[] = [
+ {
+ id: "msg-file-href-absolute-non-whitelisted-link",
+ kind: "message",
+ role: "assistant",
+ text: `Open [app file](${linkedPath})`,
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByText("app file"));
+ expect(openFileLinkMock).toHaveBeenCalledWith(linkedPath);
+ });
+
+ it("decodes percent-encoded href file paths before opening", () => {
+ const items: ConversationItem[] = [
+ {
+ id: "msg-file-href-encoded-link",
+ kind: "message",
+ role: "assistant",
+ text: "Open [guide](./docs/My%20Guide.md)",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByText("guide"));
+ expect(openFileLinkMock).toHaveBeenCalledWith("./docs/My Guide.md");
+ });
+
+ it("keeps non-file relative links as normal markdown links", () => {
+ const items: ConversationItem[] = [
+ {
+ id: "msg-help-href-link",
+ kind: "message",
+ role: "assistant",
+ text: "See [Help](/help/getting-started)",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ const helpLink = screen.getByText("Help").closest("a");
+ expect(helpLink?.getAttribute("href")).toBe("/help/getting-started");
+ fireEvent.click(screen.getByText("Help"));
+ expect(openFileLinkMock).not.toHaveBeenCalled();
+ });
+
+ it("keeps route-like absolute links as normal markdown links", () => {
+ const items: ConversationItem[] = [
+ {
+ id: "msg-help-workspace-route-link",
+ kind: "message",
+ role: "assistant",
+ text: "See [Workspace Home](/workspace/settings)",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ const link = screen.getByText("Workspace Home").closest("a");
+ expect(link?.getAttribute("href")).toBe("/workspace/settings");
+ fireEvent.click(screen.getByText("Workspace Home"));
+ expect(openFileLinkMock).not.toHaveBeenCalled();
+ });
+
+ it("keeps dot-relative non-file links as normal markdown links", () => {
+ const items: ConversationItem[] = [
+ {
+ id: "msg-help-dot-relative-href-link",
+ kind: "message",
+ role: "assistant",
+ text: "See [Help](./help/getting-started)",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ const helpLink = screen.getByText("Help").closest("a");
+ expect(helpLink?.getAttribute("href")).toBe("./help/getting-started");
+ fireEvent.click(screen.getByText("Help"));
+ expect(openFileLinkMock).not.toHaveBeenCalled();
+ });
+
+ it("does not crash or navigate on malformed codex-file links", () => {
+ const items: ConversationItem[] = [
+ {
+ id: "msg-malformed-file-link",
+ kind: "message",
+ role: "assistant",
+ text: "Bad [path](codex-file:%E0%A4%A)",
+ },
+ ];
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByText("path"));
+ expect(openFileLinkMock).not.toHaveBeenCalled();
+ });
+
it("hides file parent paths when message file path display is disabled", () => {
const items: ConversationItem[] = [
{