From aa5b8be353614ffb0f94fec6b02ac76bb54e817a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 15 Jun 2026 23:27:41 +0100 Subject: [PATCH] fix: open markdown file links in editor (#1388) --- .../shared/markdown-components.test.tsx | 74 +++++++++- .../components/shared/markdown-components.tsx | 137 ++++++++++++++++-- .../shared/memoized-markdown.test.tsx | 3 +- .../components/shared/memoized-markdown.tsx | 39 +++-- .../task/chat/__evals__/render-cost.test.tsx | 3 +- .../components/task/chat/message-renderer.tsx | 14 ++ .../task/chat/messages/chat-message.test.tsx | 24 +++ .../task/chat/messages/chat-message.tsx | 67 +++++++-- .../use-file-editors.build-state.test.ts | 4 +- 9 files changed, 322 insertions(+), 43 deletions(-) diff --git a/apps/web/components/shared/markdown-components.test.tsx b/apps/web/components/shared/markdown-components.test.tsx index 40fa6d9661..ab4e0fc1b2 100644 --- a/apps/web/components/shared/markdown-components.test.tsx +++ b/apps/web/components/shared/markdown-components.test.tsx @@ -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 }) =>
{code}
, @@ -20,6 +34,16 @@ vi.mock("@/components/task/chat/messages/inline-code", () => ({ InlineCode: ({ children }: { children: ReactNode }) => {children}, })); +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( @@ -28,7 +52,19 @@ function renderMarkdown(source: string): string { ); } +function Markdown({ children }: { children: string }) { + return ( + + {children} + + ); +} + 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( + + {"[spec.md](/root/.kandev/tasks/example/kandev/docs/specs/native/spec.md)"} + , + ); + + 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({"[plan.md](docs/specs/native/plan.md)"}); + + 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({"[service](api.service.com)"}); + + 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", () => { diff --git a/apps/web/components/shared/markdown-components.tsx b/apps/web/components/shared/markdown-components.tsx index c912a7836b..47f8b38c91 100644 --- a/apps/web/components/shared/markdown-components.tsx +++ b/apps/web/components/shared/markdown-components.tsx @@ -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({}); + 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) => { + event.preventDefault(); + openFile(filePath); + } + : undefined; + + return ( + + {children} + + ); +} + +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 ; +} + +function MarkdownLink(props: MarkdownLinkProps) { + const linkContext = useContext(MarkdownFileLinkContext); + if (linkContext.onOpenFile) { + return ( + + ); + } + return ; +} + /** * Shared markdown component overrides for ReactMarkdown. * Element styles (headings, lists, inline code, etc.) are handled by @@ -74,18 +196,7 @@ export const markdownComponents = { } return {content}; }, - a: ({ href, children }: { href?: string; children?: ReactNode }) => { - const isInternal = href?.startsWith("/") || href?.startsWith("#"); - return ( - - {children} - - ); - }, + a: MarkdownLink, table: ({ children }: { children?: ReactNode }) => (
{children}
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, + })} 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 (
@@ -294,7 +323,11 @@ function AgentMessageContent({ comment, showRaw, onToggleRaw, showRichBlocks }: ) : (
- + {showRichBlocks ? : null}
)} @@ -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} /> ); }); diff --git a/apps/web/hooks/use-file-editors.build-state.test.ts b/apps/web/hooks/use-file-editors.build-state.test.ts index d0ea66a9eb..108b31f201 100644 --- a/apps/web/hooks/use-file-editors.build-state.test.ts +++ b/apps/web/hooks/use-file-editors.build-state.test.ts @@ -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);