import { isValidElement, useEffect, useId, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; import { useTheme } from "../context/ThemeContext"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { Link } from "@/lib/router"; import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference"; import { remarkSoftBreaks } from "../lib/remark-soft-breaks"; import { StatusIcon } from "./StatusIcon"; interface MarkdownBodyProps { children: string; className?: string; style?: React.CSSProperties; softBreaks?: boolean; linkIssueReferences?: boolean; /** Optional resolver for relative image paths (e.g. within export packages) */ resolveImageSrc?: (src: string) => string | null; /** Called when a user clicks an inline image */ onImageClick?: (src: string) => void; } let mermaidLoaderPromise: Promise | null = null; function MarkdownIssueLink({ issuePathId, href, children, }: { issuePathId: string; href: string; children: ReactNode; }) { const { data } = useQuery({ queryKey: queryKeys.issues.detail(issuePathId), queryFn: () => issuesApi.get(issuePathId), staleTime: 60_000, }); return ( {data ? : null} {children} ); } function loadMermaid() { if (!mermaidLoaderPromise) { mermaidLoaderPromise = import("mermaid").then((module) => module.default); } return mermaidLoaderPromise; } const wrapAnywhereStyle: React.CSSProperties = { overflowWrap: "anywhere", wordBreak: "break-word", }; const scrollableBlockStyle: React.CSSProperties = { maxWidth: "100%", overflowX: "auto", }; function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties { return { ...wrapAnywhereStyle, ...style, }; } function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProperties { return { ...scrollableBlockStyle, ...style, }; } function flattenText(value: ReactNode): string { if (value == null) return ""; if (typeof value === "string" || typeof value === "number") return String(value); if (Array.isArray(value)) return value.map((item) => flattenText(item)).join(""); return ""; } function extractMermaidSource(children: ReactNode): string | null { if (!isValidElement(children)) return null; const childProps = children.props as { className?: unknown; children?: ReactNode }; if (typeof childProps.className !== "string") return null; if (!/\blanguage-mermaid\b/i.test(childProps.className)) return null; return flattenText(childProps.children).replace(/\n$/, ""); } function safeMarkdownUrlTransform(url: string): string { return parseMentionChipHref(url) ? url : defaultUrlTransform(url); } function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); const [svg, setSvg] = useState(null); const [error, setError] = useState(null); useEffect(() => { let active = true; setSvg(null); setError(null); loadMermaid() .then(async (mermaid) => { mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: darkMode ? "dark" : "default", fontFamily: "inherit", suppressErrorRendering: true, }); const rendered = await mermaid.render(`paperclip-mermaid-${renderId}`, source); if (!active) return; setSvg(rendered.svg); }) .catch((err) => { if (!active) return; const message = err instanceof Error && err.message ? err.message : "Failed to render Mermaid diagram."; setError(message); }); return () => { active = false; }; }, [darkMode, renderId, source]); return (
{svg ? (
) : ( <>

{error ? `Unable to render Mermaid diagram: ${error}` : "Rendering Mermaid diagram..."}

            {source}
          
)}
); } export function MarkdownBody({ children, className, style, softBreaks = true, linkIssueReferences = true, resolveImageSrc, onImageClick, }: MarkdownBodyProps) { const { theme } = useTheme(); const remarkPlugins: NonNullable = [remarkGfm]; if (linkIssueReferences) { remarkPlugins.push(remarkLinkIssueReferences); } if (softBreaks) { remarkPlugins.push(remarkSoftBreaks); } const components: Components = { p: ({ node: _node, style: paragraphStyle, children: paragraphChildren, ...paragraphProps }) => (

{paragraphChildren}

), li: ({ node: _node, style: listItemStyle, children: listItemChildren, ...listItemProps }) => (
  • {listItemChildren}
  • ), blockquote: ({ node: _node, style: blockquoteStyle, children: blockquoteChildren, ...blockquoteProps }) => (
    {blockquoteChildren}
    ), td: ({ node: _node, style: tableCellStyle, children: tableCellChildren, ...tableCellProps }) => ( {tableCellChildren} ), th: ({ node: _node, style: tableHeaderStyle, children: tableHeaderChildren, ...tableHeaderProps }) => ( {tableHeaderChildren} ), pre: ({ node: _node, children: preChildren, ...preProps }) => { const mermaidSource = extractMermaidSource(preChildren); if (mermaidSource) { return ; } return
    {preChildren}
    ; }, code: ({ node: _node, style: codeStyle, children: codeChildren, ...codeProps }) => ( {codeChildren} ), a: ({ href, style: linkStyle, children: linkChildren }) => { const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null; if (issueRef) { return ( {linkChildren} ); } const parsed = href ? parseMentionChipHref(href) : null; if (parsed) { const targetHref = parsed.kind === "project" ? `/projects/${parsed.projectId}` : parsed.kind === "issue" ? `/issues/${parsed.identifier}` : parsed.kind === "skill" ? `/skills/${parsed.skillId}` : parsed.kind === "user" ? "/company/settings/access" : `/agents/${parsed.agentId}`; return ( {linkChildren} ); } return ( {linkChildren} ); }, }; if (resolveImageSrc || onImageClick) { components.img = ({ node: _node, src, alt, ...imgProps }) => { const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null; const finalSrc = resolved ?? src; return ( {alt { e.preventDefault(); onImageClick(finalSrc); } : undefined} style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined} /> ); }; } return (
    {children}
    ); }