fix: open markdown file links in editor (#1388)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user