diff --git a/apps/web/components/shared/memoized-markdown.test.tsx b/apps/web/components/shared/memoized-markdown.test.tsx
index 1b25558199..b94cdcf7a8 100644
--- a/apps/web/components/shared/memoized-markdown.test.tsx
+++ b/apps/web/components/shared/memoized-markdown.test.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { createContext, useState } from "react";
import { fireEvent, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -13,6 +13,7 @@ vi.mock("react-markdown", () => ({
// Stub the component overrides so the test stays light (no shiki / mermaid).
vi.mock("@/components/shared/markdown-components", () => ({
+ MarkdownFileLinkContext: createContext({}),
markdownComponents: {},
remarkPlugins: [],
}));
diff --git a/apps/web/components/shared/memoized-markdown.tsx b/apps/web/components/shared/memoized-markdown.tsx
index 3aedf0002c..d2a3423e98 100644
--- a/apps/web/components/shared/memoized-markdown.tsx
+++ b/apps/web/components/shared/memoized-markdown.tsx
@@ -1,23 +1,40 @@
"use client";
-import { memo } from "react";
+import { memo, useMemo } from "react";
import ReactMarkdown from "react-markdown";
-import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components";
+import {
+ MarkdownFileLinkContext,
+ markdownComponents,
+ remarkPlugins,
+ type MarkdownFileLinkContextValue,
+} from "@/components/shared/markdown-components";
import { normalizeCached } from "@/lib/markdown/normalize-cache";
/**
* Markdown renderer behind a `memo` boundary keyed on the `content` string.
*
- * `content` is a primitive, so React compares it by value. Object-ref churn
- * from a message refetch can therefore never trigger a re-parse: an identical
- * string bails out of the memo and re-uses the previously parsed element tree.
- * The normalized string itself is also cached (LRU) so two messages with the
- * same content share a single normalize pass.
+ * `content` is a primitive, so React compares it by value. Keep optional
+ * file-link props stable at the caller when possible; identical props bail out
+ * of the memo and re-use the previously parsed element tree. The normalized
+ * string itself is also cached (LRU) so two messages with the same content
+ * share a single normalize pass.
*/
-export const MemoizedMarkdown = memo(function MemoizedMarkdown({ content }: { content: string }) {
+type MemoizedMarkdownProps = MarkdownFileLinkContextValue & {
+ content: string;
+};
+
+export const MemoizedMarkdown = memo(function MemoizedMarkdown({
+ content,
+ worktreePath,
+ onOpenFile,
+}: MemoizedMarkdownProps) {
+ const fileLinkContext = useMemo(() => ({ worktreePath, onOpenFile }), [worktreePath, onOpenFile]);
+
return (
-
- {normalizeCached(content)}
-
+
+
+ {normalizeCached(content)}
+
+
);
});
diff --git a/apps/web/components/task/chat/__evals__/render-cost.test.tsx b/apps/web/components/task/chat/__evals__/render-cost.test.tsx
index be0c1f8770..a37b852a20 100644
--- a/apps/web/components/task/chat/__evals__/render-cost.test.tsx
+++ b/apps/web/components/task/chat/__evals__/render-cost.test.tsx
@@ -7,7 +7,7 @@
* update pattern. Wave 1 owns the parse-count assertions; Wave 2/3 extend the
* row-render assertions once callbacks + store identity are stabilized.
*/
-import { memo, useState } from "react";
+import { createContext, memo, useState } from "react";
import { act, fireEvent, render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -16,6 +16,7 @@ vi.mock("react-markdown", () => ({
}));
vi.mock("@/components/shared/markdown-components", () => ({
+ MarkdownFileLinkContext: createContext({}),
markdownComponents: {},
remarkPlugins: [],
}));
diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx
index faa5a59d93..4b56f669b7 100644
--- a/apps/web/components/task/chat/message-renderer.tsx
+++ b/apps/web/components/task/chat/message-renderer.tsx
@@ -95,11 +95,15 @@ function TaskDescriptionMessage({
comment,
taskId,
sessionId,
+ worktreePath,
+ onOpenFile,
onScrollToMessage,
}: {
comment: Message;
taskId?: string;
sessionId?: string;
+ worktreePath?: string;
+ onOpenFile?: (path: string) => void;
onScrollToMessage?: (messageId: string) => void;
}) {
const sessionState = useSessionStateValue(sessionId);
@@ -113,6 +117,8 @@ function TaskDescriptionMessage({
className="bg-muted/40 text-foreground border-border/60"
showRichBlocks={comment.type === "message" || comment.type === "content" || !comment.type}
sessionId={sessionId}
+ worktreePath={worktreePath}
+ onOpenFile={onOpenFile}
onScrollToMessage={onScrollToMessage}
/>
);
@@ -126,6 +132,8 @@ function TaskDescriptionMessage({
label="You"
className="bg-primary/10 text-foreground border-primary/30"
sessionId={sessionId}
+ worktreePath={worktreePath}
+ onOpenFile={onOpenFile}
onScrollToMessage={onScrollToMessage}
/>
{showStartButton &&
}
@@ -305,6 +313,8 @@ const adapters: MessageAdapter[] = [
comment={comment}
taskId={ctx.taskId}
sessionId={ctx.sessionId}
+ worktreePath={ctx.worktreePath}
+ onOpenFile={ctx.onOpenFile}
onScrollToMessage={ctx.onScrollToMessage}
/>
);
@@ -316,6 +326,8 @@ const adapters: MessageAdapter[] = [
label="You"
className="bg-primary/10 text-foreground border-primary/30"
sessionId={ctx.sessionId}
+ worktreePath={ctx.worktreePath}
+ onOpenFile={ctx.onOpenFile}
onScrollToMessage={ctx.onScrollToMessage}
/>
);
@@ -327,6 +339,8 @@ const adapters: MessageAdapter[] = [
className="bg-muted/40 text-foreground border-border/60"
showRichBlocks={comment.type === "message" || comment.type === "content" || !comment.type}
sessionId={ctx.sessionId}
+ worktreePath={ctx.worktreePath}
+ onOpenFile={ctx.onOpenFile}
onScrollToMessage={ctx.onScrollToMessage}
/>
);
diff --git a/apps/web/components/task/chat/messages/chat-message.test.tsx b/apps/web/components/task/chat/messages/chat-message.test.tsx
index fb363cb81f..812763e234 100644
--- a/apps/web/components/task/chat/messages/chat-message.test.tsx
+++ b/apps/web/components/task/chat/messages/chat-message.test.tsx
@@ -203,6 +203,30 @@ describe("ChatMessage sender badge", () => {
});
describe("ChatMessage agent session config metadata", () => {
+ it("opens markdown file links with the provided chat file opener", () => {
+ const onOpenFile = vi.fn();
+ const Wrapper = wrapper([]);
+
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("link", { name: "spec.md" }));
+
+ expect(onOpenFile).toHaveBeenCalledWith("docs/specs/native/spec.md");
+ });
+
it("shows session config options next to the model", () => {
renderAgentMessageWithSession({
agent_profile_snapshot: {
diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx
index bbadc6d55d..c8762b1c4f 100644
--- a/apps/web/components/task/chat/messages/chat-message.tsx
+++ b/apps/web/components/task/chat/messages/chat-message.tsx
@@ -23,6 +23,8 @@ type ChatMessageProps = {
className: string;
showRichBlocks?: boolean;
sessionId?: string | null;
+ worktreePath?: string;
+ onOpenFile?: (path: string) => void;
onScrollToMessage?: (messageId: string) => void;
};
@@ -70,20 +72,32 @@ function renderContentWithFileRefs(content: string): React.ReactNode[] {
// ── Markdown component overrides imported from shared/markdown-components ─────
-function renderUserMessageBody(
- hasContent: boolean,
- showRaw: boolean,
- hasAttachments: boolean,
- content: string,
- rawContent?: string,
-): React.ReactNode {
+type UserMessageBodyOptions = {
+ hasContent: boolean;
+ showRaw: boolean;
+ hasAttachments: boolean;
+ content: string;
+ rawContent?: string;
+ worktreePath?: string;
+ onOpenFile?: (path: string) => void;
+};
+
+function renderUserMessageBody({
+ hasContent,
+ showRaw,
+ hasAttachments,
+ content,
+ rawContent,
+ worktreePath,
+ onOpenFile,
+}: UserMessageBodyOptions): React.ReactNode {
if (hasContent && showRaw) {
return
{rawContent || content};
}
if (hasContent) {
return (
-
+
);
}
@@ -100,6 +114,8 @@ type UserMessageProps = {
showRaw: boolean;
onToggleRaw: () => void;
sessionId?: string | null;
+ worktreePath?: string;
+ onOpenFile?: (path: string) => void;
onScrollToMessage?: (messageId: string) => void;
};
@@ -194,6 +210,8 @@ function UserMessageContent({
showRaw,
onToggleRaw,
sessionId,
+ worktreePath,
+ onOpenFile,
onScrollToMessage,
}: UserMessageProps) {
const userNavigation = useUserMessageNavigation(sessionId ?? null, comment.id);
@@ -242,13 +260,15 @@ function UserMessageContent({
))}
)}
- {renderUserMessageBody(
+ {renderUserMessageBody({
hasContent,
showRaw,
hasAttachments,
- comment.content,
- comment.raw_content,
- )}
+ content: comment.content,
+ rawContent: comment.raw_content,
+ worktreePath,
+ onOpenFile,
+ })}