fix: open markdown file links in editor (#1388)

This commit is contained in:
Carlos Florêncio
2026-06-15 23:27:41 +01:00
committed by GitHub
parent 19c17b1321
commit aa5b8be353
9 changed files with 322 additions and 43 deletions
@@ -1,8 +1,22 @@
import type { ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { markdownComponents, normalizeMarkdown, remarkPlugins } from "./markdown-components";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const openFile = vi.hoisted(() => vi.fn());
const appState = vi.hoisted(() => ({
value: {
tasks: { activeSessionId: "session-1" },
taskSessions: {
items: {
"session-1": {
worktree_path: "/root/.kandev/tasks/example/kandev",
},
},
},
},
}));
vi.mock("@/components/shared/mermaid-block", () => ({
MermaidBlock: ({ code }: { code: string }) => <div data-kind="mermaid">{code}</div>,
@@ -20,6 +34,16 @@ vi.mock("@/components/task/chat/messages/inline-code", () => ({
InlineCode: ({ children }: { children: ReactNode }) => <code data-kind="inline">{children}</code>,
}));
vi.mock("@/hooks/use-panel-actions", () => ({
usePanelActions: () => ({ openFile }),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: typeof appState.value) => unknown) => selector(appState.value),
}));
import { markdownComponents, normalizeMarkdown, remarkPlugins } from "./markdown-components";
function renderMarkdown(source: string): string {
return renderToStaticMarkup(
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
@@ -28,7 +52,19 @@ function renderMarkdown(source: string): string {
);
}
function Markdown({ children }: { children: string }) {
return (
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
{children}
</ReactMarkdown>
);
}
describe("markdownComponents", () => {
afterEach(() => {
openFile.mockClear();
});
it("keeps mermaid keywords in inline code as inline code", () => {
const html = renderMarkdown("Metadata comes from `kanban`, `kanbanMulti`, repositories.");
@@ -52,6 +88,40 @@ describe("markdownComponents", () => {
expect(html).toContain("const source");
expect(html).not.toContain('data-kind="mermaid"');
});
it("opens absolute worktree file links in the editor", () => {
render(
<Markdown>
{"[spec.md](/root/.kandev/tasks/example/kandev/docs/specs/native/spec.md)"}
</Markdown>,
);
fireEvent.click(screen.getByRole("link", { name: "spec.md" }));
expect(openFile).toHaveBeenCalledWith("docs/specs/native/spec.md");
});
it("opens relative file links in the editor", () => {
render(<Markdown>{"[plan.md](docs/specs/native/plan.md)"}</Markdown>);
const link = screen.getByRole("link", { name: "plan.md" });
expect(link.getAttribute("target")).toBe("_self");
fireEvent.click(link);
expect(openFile).toHaveBeenCalledWith("docs/specs/native/plan.md");
});
it("does not treat bare domains as relative file links", () => {
render(<Markdown>{"[service](api.service.com)"}</Markdown>);
const link = screen.getByRole("link", { name: "service" });
link.addEventListener("click", (event) => event.preventDefault());
fireEvent.click(link);
expect(openFile).not.toHaveBeenCalled();
expect(link.getAttribute("target")).toBe("_blank");
});
});
describe("normalizeMarkdown", () => {
@@ -1,6 +1,6 @@
"use client";
import { isValidElement, type ReactNode } from "react";
import { createContext, isValidElement, useContext, type MouseEvent, type ReactNode } from "react";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import remarkGemoji from "remark-gemoji";
@@ -8,6 +8,8 @@ import { InlineCode } from "@/components/task/chat/messages/inline-code";
import { CodeBlock } from "@/components/task/chat/messages/code-block";
import { MermaidBlock } from "@/components/shared/mermaid-block";
import { isMermaidContent } from "@/components/editors/tiptap/tiptap-mermaid-extension";
import { usePanelActions } from "@/hooks/use-panel-actions";
import { useAppStore } from "@/components/state-provider";
/** Shared remark plugins used by all markdown renderers */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -49,10 +51,130 @@ type MarkdownCodeProps = {
children?: ReactNode;
};
export type MarkdownFileLinkContextValue = {
worktreePath?: string | null;
onOpenFile?: (path: string) => void;
};
export const MarkdownFileLinkContext = createContext<MarkdownFileLinkContextValue>({});
function isBlockCode(rawContent: string, hasLanguage: boolean): boolean {
return hasLanguage || rawContent.includes("\n");
}
const WEB_TLD_EXTENSIONS = new Set(["ai", "app", "cloud", "co", "com", "dev", "io", "net", "org"]);
function looksLikeFilePath(path: string): boolean {
const lastSegment = path.split("/").pop() ?? "";
if (!lastSegment.includes(".") || path.endsWith("/")) return false;
const extension = lastSegment.split(".").pop() ?? "";
if (!/^[a-z0-9]{1,8}$/i.test(extension)) return false;
return !WEB_TLD_EXTENSIONS.has(extension.toLowerCase());
}
function isExternalHref(href: string): boolean {
return /^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith("//");
}
function stripHashAndQuery(href: string): string {
return href.split(/[?#]/, 1)[0] ?? "";
}
function decodeHrefPath(href: string): string | null {
try {
return decodeURIComponent(stripHashAndQuery(href));
} catch {
return null;
}
}
function hasParentTraversal(path: string): boolean {
return path.split("/").includes("..");
}
function resolveMarkdownFileHref(
href: string | undefined,
worktreePath: string | null | undefined,
) {
if (!href || href.startsWith("#") || isExternalHref(href)) return null;
const path = decodeHrefPath(href);
if (!path || path.startsWith("~/") || hasParentTraversal(path)) return null;
if (path.startsWith("/")) {
const normalizedRoot = worktreePath?.replace(/\\/g, "/").replace(/\/$/, "");
const normalizedPath = path.replace(/\\/g, "/");
if (!normalizedRoot || !normalizedPath.startsWith(`${normalizedRoot}/`)) return null;
const relativePath = normalizedPath.slice(normalizedRoot.length + 1);
return looksLikeFilePath(relativePath) ? relativePath : null;
}
const normalizedPath = path.replace(/\\/g, "/").replace(/^\.\//, "");
if (normalizedPath.startsWith("../")) return null;
return looksLikeFilePath(normalizedPath) ? normalizedPath : null;
}
type MarkdownLinkProps = {
href?: string;
children?: ReactNode;
};
function MarkdownFileAnchor({
href,
children,
worktreePath,
openFile,
}: MarkdownLinkProps & {
worktreePath: string | null | undefined;
openFile: (path: string) => void;
}) {
const filePath = resolveMarkdownFileHref(href, worktreePath);
const isInternal = !!filePath || href?.startsWith("/") || href?.startsWith("#");
const handleClick = filePath
? (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
openFile(filePath);
}
: undefined;
return (
<a
href={href}
target={isInternal ? "_self" : "_blank"}
rel={isInternal ? undefined : "noopener noreferrer"}
onClick={handleClick}
>
{children}
</a>
);
}
function MarkdownFallbackLink(props: MarkdownLinkProps) {
const { openFile } = usePanelActions();
const worktreePath = useAppStore((state) => {
const sessionId = state.tasks.activeSessionId;
if (!sessionId) return null;
return state.taskSessions.items[sessionId]?.worktree_path ?? null;
});
return <MarkdownFileAnchor {...props} worktreePath={worktreePath} openFile={openFile} />;
}
function MarkdownLink(props: MarkdownLinkProps) {
const linkContext = useContext(MarkdownFileLinkContext);
if (linkContext.onOpenFile) {
return (
<MarkdownFileAnchor
{...props}
worktreePath={linkContext.worktreePath}
openFile={linkContext.onOpenFile}
/>
);
}
return <MarkdownFallbackLink {...props} />;
}
/**
* Shared markdown component overrides for ReactMarkdown.
* Element styles (headings, lists, inline code, etc.) are handled by
@@ -74,18 +196,7 @@ export const markdownComponents = {
}
return <InlineCode>{content}</InlineCode>;
},
a: ({ href, children }: { href?: string; children?: ReactNode }) => {
const isInternal = href?.startsWith("/") || href?.startsWith("#");
return (
<a
href={href}
target={isInternal ? "_self" : "_blank"}
rel={isInternal ? undefined : "noopener noreferrer"}
>
{children}
</a>
);
},
a: MarkdownLink,
table: ({ children }: { children?: ReactNode }) => (
<div className="overflow-x-auto">
<table>{children}</table>
@@ -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: [],
}));
@@ -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 (
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
{normalizeCached(content)}
</ReactMarkdown>
<MarkdownFileLinkContext.Provider value={fileLinkContext}>
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownComponents}>
{normalizeCached(content)}
</ReactMarkdown>
</MarkdownFileLinkContext.Provider>
);
});
@@ -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: [],
}));
@@ -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 && <TaskDescriptionStartButton taskId={taskId!} sessionId={sessionId!} />}
@@ -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}
/>
);
@@ -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(
<Wrapper>
<ChatMessage
comment={userMessage({
author_type: "agent",
content: "[spec.md](/root/.kandev/tasks/example/kandev/docs/specs/native/spec.md)",
})}
label="Message"
className=""
worktreePath="/root/.kandev/tasks/example/kandev"
onOpenFile={onOpenFile}
/>
</Wrapper>,
);
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: {
@@ -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 <pre className="whitespace-pre-wrap font-mono text-xs">{rawContent || content}</pre>;
}
if (hasContent) {
return (
<div className="markdown-body markdown-body-user max-w-none">
<MemoizedMarkdown content={content} />
<MemoizedMarkdown content={content} worktreePath={worktreePath} onOpenFile={onOpenFile} />
</div>
);
}
@@ -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({
))}
</div>
)}
{renderUserMessageBody(
{renderUserMessageBody({
hasContent,
showRaw,
hasAttachments,
comment.content,
comment.raw_content,
)}
content: comment.content,
rawContent: comment.raw_content,
worktreePath,
onOpenFile,
})}
</div>
<MessageActions
message={comment}
@@ -282,9 +302,18 @@ type AgentMessageProps = {
showRaw: boolean;
onToggleRaw: () => void;
showRichBlocks?: boolean;
worktreePath?: string;
onOpenFile?: (path: string) => void;
};
function AgentMessageContent({ comment, showRaw, onToggleRaw, showRichBlocks }: AgentMessageProps) {
function AgentMessageContent({
comment,
showRaw,
onToggleRaw,
showRichBlocks,
worktreePath,
onOpenFile,
}: AgentMessageProps) {
return (
<div className="flex items-start gap-2 sm:gap-3 w-full group">
<div className="flex-1 min-w-0">
@@ -294,7 +323,11 @@ function AgentMessageContent({ comment, showRaw, onToggleRaw, showRichBlocks }:
</pre>
) : (
<div className="markdown-body max-w-none">
<MemoizedMarkdown content={comment.content || "(empty)"} />
<MemoizedMarkdown
content={comment.content || "(empty)"}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
/>
{showRichBlocks ? <RichBlocks comment={comment} /> : null}
</div>
)}
@@ -321,6 +354,8 @@ export const ChatMessage = memo(function ChatMessage({
className,
showRichBlocks,
sessionId,
worktreePath,
onOpenFile,
onScrollToMessage,
}: ChatMessageProps) {
const [showRaw, setShowRaw] = useState(false);
@@ -353,6 +388,8 @@ export const ChatMessage = memo(function ChatMessage({
showRaw={showRaw}
onToggleRaw={toggleRaw}
sessionId={sessionId}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
onScrollToMessage={onScrollToMessage}
/>
);
@@ -364,6 +401,8 @@ export const ChatMessage = memo(function ChatMessage({
showRaw={showRaw}
onToggleRaw={toggleRaw}
showRichBlocks={showRichBlocks}
worktreePath={worktreePath}
onOpenFile={onOpenFile}
/>
);
});
@@ -47,7 +47,9 @@ describe("buildFileEditorState", () => {
});
describe("fetchFileEditorState", () => {
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
});
it("returns the built state (with repo) when the session is unchanged", async () => {
mockRequestFileContent.mockResolvedValueOnce(RESPONSE);