import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; import { Link } from "@/lib/router"; import { useTheme } from "../context/ThemeContext"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; 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; /** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */ enableWikiLinks?: boolean; /** Base href used for wikilinks when no resolver is supplied. */ wikiLinkRoot?: string; /** Optional href resolver for wikilinks. Return null to leave a token as plain text. */ resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined; /** 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, children, }: { issuePathId: string; children: ReactNode; }) { const { data } = useQuery({ queryKey: queryKeys.issues.detail(issuePathId), queryFn: () => issuesApi.get(issuePathId), staleTime: 60_000, }); const identifier = data?.identifier ?? issuePathId; const title = data?.title ?? identifier; const status = data?.status; const issueLabel = title !== identifier ? `Issue ${identifier}: ${title}` : `Issue ${identifier}`; return ( {status ? ( ) : 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", }; const tableCellWrapStyle: React.CSSProperties = { overflowWrap: "anywhere", wordBreak: "normal", }; function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties { return { ...wrapAnywhereStyle, ...style, }; } function mergeTableCellStyle(style?: React.CSSProperties): React.CSSProperties { return { ...tableCellWrapStyle, ...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); } type MarkdownAstNode = { type?: string; value?: string; children?: MarkdownAstNode[]; url?: string; title?: string | null; data?: { hProperties?: Record; }; }; type ParsedWikiLink = { target: string; label: string; }; const WIKI_LINK_PATTERN = /\[\[([^\]\r\n]+)\]\]/g; const WIKI_LINK_SKIP_PARENT_TYPES = new Set([ "definition", "image", "imageReference", "link", "linkReference", ]); function parseWikiLinkBody(body: string): ParsedWikiLink | null { const [rawTarget, ...rawLabelParts] = body.split("|"); const target = rawTarget?.trim() ?? ""; const label = rawLabelParts.length > 0 ? rawLabelParts.join("|").trim() : target; if (!target || target.includes("[") || target.includes("]")) return null; return { target, label: label || target, }; } function encodeWikiLinkTarget(target: string): string | null { const trimmed = target.trim(); if (!trimmed || /^[a-z][a-z\d+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return null; const hashIndex = trimmed.indexOf("#"); const rawPath = (hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed) .trim() .replace(/^\/+/, ""); if ( !rawPath || rawPath.includes("\\") || rawPath.split("/").some((segment) => !segment || segment === "." || segment === "..") ) { return null; } const encodedPath = rawPath.split("/").map((segment) => encodeURIComponent(segment)).join("/"); const rawHash = hashIndex >= 0 ? trimmed.slice(hashIndex + 1).trim() : ""; return rawHash ? `${encodedPath}#${encodeURIComponent(rawHash)}` : encodedPath; } function defaultWikiLinkHref(target: string, wikiLinkRoot?: string): string | null { const encodedTarget = encodeWikiLinkTarget(target); if (!encodedTarget) return null; const root = wikiLinkRoot?.trim().replace(/\/+$/, "") ?? ""; return root ? `${root}/${encodedTarget}` : encodedTarget; } function createWikiLinkNode(href: string, wikiLink: ParsedWikiLink): MarkdownAstNode { return { type: "link", url: href, title: null, data: { hProperties: { "data-paperclip-wiki-link": "true", "data-paperclip-wiki-target": wikiLink.target, }, }, children: [{ type: "text", value: wikiLink.label }], }; } function splitTextByWikiLinks( value: string, options: { wikiLinkRoot?: string; resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined; }, ): MarkdownAstNode[] { const nodes: MarkdownAstNode[] = []; let lastIndex = 0; for (const match of value.matchAll(WIKI_LINK_PATTERN)) { const raw = match[0] ?? ""; const body = match[1] ?? ""; const start = match.index ?? 0; if (start > lastIndex) { nodes.push({ type: "text", value: value.slice(lastIndex, start) }); } const wikiLink = parseWikiLinkBody(body); let resolvedHref: string | null = null; if (wikiLink) { if (options.resolveWikiLinkHref) { const customHref = options.resolveWikiLinkHref(wikiLink.target, wikiLink.label); resolvedHref = customHref === undefined ? defaultWikiLinkHref(wikiLink.target, options.wikiLinkRoot) : customHref; } else { resolvedHref = defaultWikiLinkHref(wikiLink.target, options.wikiLinkRoot); } } if (wikiLink && resolvedHref) { nodes.push(createWikiLinkNode(resolvedHref, wikiLink)); } else { nodes.push({ type: "text", value: raw }); } lastIndex = start + raw.length; } if (lastIndex < value.length) { nodes.push({ type: "text", value: value.slice(lastIndex) }); } return nodes; } function transformWikiLinkChildren( node: MarkdownAstNode, options: { wikiLinkRoot?: string; resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined; }, ) { if (!node.children || WIKI_LINK_SKIP_PARENT_TYPES.has(node.type ?? "")) return; node.children = node.children.flatMap((child) => { if (child.type === "text" && typeof child.value === "string" && child.value.includes("[[")) { return splitTextByWikiLinks(child.value, options); } transformWikiLinkChildren(child, options); return child; }); } function createRemarkWikiLinks(options: { wikiLinkRoot?: string; resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined; }) { return function remarkWikiLinks() { return (tree: MarkdownAstNode) => { transformWikiLinkChildren(tree, options); }; }; } function isGitHubUrl(href: string | null | undefined): boolean { if (!href) return false; try { const url = new URL(href); return url.protocol === "https:" && (url.hostname === "github.com" || url.hostname === "www.github.com"); } catch { return false; } } function isExternalHttpUrl(href: string | null | undefined): boolean { if (!href) return false; try { const url = new URL(href); if (url.protocol !== "http:" && url.protocol !== "https:") return false; if (typeof window === "undefined") return true; return url.origin !== window.location.origin; } catch { return false; } } function renderLinkBody( children: ReactNode, leadingIcon: ReactNode, trailingIcon: ReactNode, ): ReactNode { if (!leadingIcon && !trailingIcon) return children; // React-markdown can pass arrays/elements for styled link text; the nowrap // splitting below is intentionally limited to plain text links. if (typeof children === "string" && children.length > 0) { if (children.length === 1) { return ( {leadingIcon} {children} {trailingIcon} ); } const first = children[0]; const last = children[children.length - 1]; const middle = children.slice(1, -1); return ( <> {leadingIcon ? ( {leadingIcon} {first} ) : first} {middle} {trailingIcon ? ( {last} {trailingIcon} ) : last} ); } return ( <> {leadingIcon} {children} {trailingIcon} ); } function CodeBlock({ children, preProps, }: { children: ReactNode; preProps: React.HTMLAttributes; }) { const [copied, setCopied] = useState(false); const [failed, setFailed] = useState(false); const [wrapLines, setWrapLines] = useState(false); const preRef = useRef(null); const timerRef = useRef>(undefined); useEffect(() => () => clearTimeout(timerRef.current), []); const handleCopy = useCallback(async () => { const text = preRef.current?.innerText ?? flattenText(children); try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); } else { const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; textarea.style.left = "-9999px"; document.body.appendChild(textarea); try { textarea.select(); const success = document.execCommand("copy"); if (!success) throw new Error("execCommand copy failed"); } finally { document.body.removeChild(textarea); } } setFailed(false); setCopied(true); } catch { setFailed(true); setCopied(true); } clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { setCopied(false); setFailed(false); }, 1500); }, [children]); const copyLabel = failed ? "Copy failed" : copied ? "Copied!" : "Copy"; const wrapLabel = wrapLines ? "Unwrap lines" : "Wrap lines"; return (
        {children}
      
); } 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, enableWikiLinks = false, wikiLinkRoot, resolveWikiLinkHref, resolveImageSrc, onImageClick, }: MarkdownBodyProps) { const { theme } = useTheme(); const remarkPlugins: NonNullable = [remarkGfm]; if (enableWikiLinks) { remarkPlugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref })); } 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}
    ), table: ({ node: _node, style: tableStyle, children: tableChildren, ...tableProps }) => (
    {tableChildren}
    ), 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: ({ node: _node, href, style: linkStyle, children: linkChildren, ...anchorProps }) => { const dataProps = anchorProps as Record; const isWikiLink = dataProps["data-paperclip-wiki-link"] === "true"; if (isWikiLink && href && !/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) { return ( {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 === "routine" ? `/routines/${parsed.routineId}` : parsed.kind === "user" ? "/company/settings/access" : `/agents/${parsed.agentId}`; return ( {linkChildren} ); } const isGitHubLink = isGitHubUrl(href); const isExternal = isExternalHttpUrl(href); const leadingIcon = isGitHubLink ? (