import { isValidElement, memo, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { Check, Copy, ExternalLink, 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 { GithubIcon } from "@/components/icons/github-icon"; import { Link, useCaseHref } from "@/lib/router"; import { useTheme } from "../context/ThemeContext"; import { useOptionalCompany } from "../context/CompanyContext"; 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 { remarkLinkCaseReferences } from "../lib/case-reference"; const CASE_HREF_RE = /^\/cases\/([A-Z][A-Z0-9]*-C\d+)$/i; /** Recover the case identifier from a `/cases/PAP-C7` href produced by the plugin. */ function caseIdentifierFromHref(href: string | undefined): string | null { if (!href) return null; const match = decodeURIComponent(href.trim()).match(CASE_HREF_RE); return match ? match[1]!.toUpperCase() : null; } import { createRemarkWorkspaceFileRefs, parseWorkspaceFileHref, WORKSPACE_FILE_HREF_PREFIX, type WorkspaceFileRefResolver, } from "../lib/remark-workspace-file-refs"; import { remarkSoftBreaks } from "../lib/remark-soft-breaks"; import { StatusIcon } from "./StatusIcon"; import { WorkspaceFileLink } from "./WorkspaceFileLink"; import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon"; import { externalObjectCategoryLabel, externalObjectLivenessLabel, externalObjectProviderLabel, } from "../lib/external-objects"; import { normalizeExternalObjectHref } from "../lib/external-object-href"; import { copyTextToClipboard } from "../lib/clipboard"; import type { ExternalObjectLivenessState, ExternalObjectStatusCategory, } from "@paperclipai/shared"; /** * Host-resolved external-object metadata for inline markdown decoration. * The renderer only consumes the host normalized fields here — plugin React * is never mounted inline (Phase 1B security review). */ export interface MarkdownExternalReference { providerKey: string | null; objectType: string | null; displayKey?: string | null; iconKey?: string | null; statusCategory: ExternalObjectStatusCategory; liveness: ExternalObjectLivenessState; statusLabel?: string | null; statusIconKey?: string | null; displayTitle?: string | null; } export type MarkdownExternalReferenceMap = Record; interface MarkdownBodyProps { children: string; className?: string; style?: React.CSSProperties; softBreaks?: boolean; linkIssueReferences?: boolean; /** * Linkify bare case identifiers (`PAP-C7`) to the case detail page. Off by * default; enabled on surfaces behind the experimental Cases flag (PAP-12969). */ linkCaseReferences?: 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 map of `normalizeExternalObjectHref(href)` → host-resolved metadata. * Hrefs in the markdown that resolve to one of these keys get the inline * status icon prefix used by §2 of the UX spec. */ externalReferences?: MarkdownExternalReferenceMap; /** 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; /** * Resolver that decides which inline-code workspace file paths may be linked * to the issue file viewer. Omitting it (or returning null) leaves every * path-shaped code span as ordinary inline code — the fail-closed default. * * Its identity must change when previously-pending references become * openable, so the markdown re-parses with the new answers. */ resolveWorkspaceFileRef?: WorkspaceFileRefResolver; } 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 MarkdownCaseLink({ identifier, children, }: { identifier: string; children: ReactNode; }) { // Cases resolve via the get-by-identifier route; navigate there on click. // Kept boxless/underlined to match the issue mention treatment. const caseHref = useCaseHref(); return ( {children} ); } function MarkdownExternalLink({ href, reference, children, }: { href: string; reference: MarkdownExternalReference; children: ReactNode; }) { const provider = externalObjectProviderLabel(reference.providerKey); const displayKey = reference.displayKey?.trim() || provider; const statusLabel = reference.statusLabel ?? externalObjectCategoryLabel(reference.statusCategory); const livenessLabel = externalObjectLivenessLabel(reference.liveness); const livenessSuffix = reference.liveness === "fresh" || reference.liveness === "unknown" ? "" : ` (${livenessLabel})`; const titleParts = [ reference.displayTitle ?? `${displayKey} ${statusLabel}`, `${displayKey} — ${statusLabel}${livenessSuffix}`, ]; const title = titleParts.filter(Boolean).join(" · "); return ( {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 codeBlockActionsStyle: React.CSSProperties = { position: "absolute", top: "0.4rem", right: "0.4rem", display: "inline-flex", alignItems: "center", gap: "0.25rem", }; export const codeBlockActionStyle: React.CSSProperties = { position: "static", opacity: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "0.25rem", minHeight: "1.55rem", padding: "0.2rem 0.4rem", borderRadius: "calc(var(--radius) - 4px)", border: "1px solid color-mix(in oklab, var(--foreground) 14%, transparent)", backgroundColor: "var(--background)", color: "var(--muted-foreground)", fontSize: "var(--text-micro)", lineHeight: 1, cursor: "pointer", }; const codeBlockWrapActionStyle: React.CSSProperties = { ...codeBlockActionStyle, width: "1.55rem", paddingInline: 0, }; const tableCellWrapStyle: React.CSSProperties = { overflowWrap: "anywhere", wordBreak: "normal", }; function isHtmlCommentNode(node: MarkdownAstNode) { return node.type === "html" && typeof node.value === "string" && /^$/.test(node.value.trim()); } function isEscapedHtmlCommentPlaceholder(node: MarkdownAstNode) { if (node.type !== "text" || typeof node.value !== "string") return false; const value = node.value.trim(); return /^\\?