import { MarkdownWordStreaming, rehypeStreamingWords } from "./MarkdownWordStreaming"; import { isValidElement, memo, useContext, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, ExternalLink, MessageCircle, WrapText } from "lucide-react"; import { useCoordinatorPresentation } from "./coordinator/CoordinatorPresentation"; import { useCoordinatorChatReference } from "./coordinator/CoordinatorChatReferences"; import { SidebarDocumentLink } from "./SidebarDocumentLink"; import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash"; 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 { ApiError } from "../api/client"; import { LockedIssueChip } from "./LockedIssueChip"; import { agentsApi } from "../api/agents"; import { getCachedIssueDetail } from "../lib/issueDetailCache"; 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 { externalObjectDisplayStatusLabel, externalObjectLivenessLabel, externalObjectProviderLabel, } from "../lib/external-objects"; import { normalizeExternalObjectHref } from "../lib/external-object-href"; import { copyTextToClipboard } from "../lib/clipboard"; import type { Agent, ExternalObjectLivenessState, ExternalObjectStatusCategory, } from "@paperclipai/shared"; function MarkdownAgentMention({ agentId, children, style }: { agentId: string; children: ReactNode; style?: React.CSSProperties; }) { const companyId = useOptionalCompany()?.selectedCompanyId; // All mentions share the company list cache; never fetch one agent per chip. const { data: agents } = useQuery({ queryKey: queryKeys.agents.list(companyId ?? "__none__"), queryFn: () => agentsApi.list(companyId!), enabled: Boolean(companyId), staleTime: 60_000, }); const appearance = agents?.find((agent) => agent.id === agentId)?.appearance; return ( {children} ); } /** * 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; /** Resolve file links to app routes; null leaves the label without a link. */ resolveLinkHref?: (href: string) => string | null; /** Called when a user clicks an inline image */ onImageClick?: (src: string, image: HTMLImageElement) => void; /** Keep untrusted decision media inert: image references and diagram source only. */ mediaMode?: "render" | "reference"; /** * 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, href, children, documentLink = false, }: { issuePathId: string; href: string; children: ReactNode; documentLink?: boolean; }) { const queryClient = useQueryClient(); const chatReference = useCoordinatorChatReference(issuePathId); const [engaged, setEngaged] = useState(false); const { data, error } = useQuery({ queryKey: queryKeys.issues.detail(issuePathId), // A transcript can mention dozens of tasks. Their full detail projections // are hover information, not prerequisites for reading this conversation. enabled: engaged && (!chatReference || documentLink), placeholderData: getCachedIssueDetail(queryClient, issuePathId), queryFn: () => issuesApi.get(issuePathId), staleTime: 60_000, // A private issue 404s on direct fetch (indistinguishable from deleted, by // design). Don't burn retries on it — settle straight to the locked chip. retry: (failureCount, err) => !(err instanceof ApiError && err.status === 404) && failureCount < 3, }); if (chatReference && !documentLink) { const suffix = href.match(/[?#].*$/)?.[0] ?? ""; return {chatReference.label} ; } // Mention of an issue this viewer can't read → existence-only locked chip // (never a title or a link), matching the locked-stub treatment on edges. if (error instanceof ApiError && error.status === 404) { return ; } if (documentLink) { return setEngaged(true)} onFocus={() => setEngaged(true)}> {children} ; } const identifier = data?.identifier ?? issuePathId; const title = data?.title ?? identifier; const status = data?.status; const issueLabel = title !== identifier ? `Issue ${identifier}: ${title}` : `Issue ${identifier}`; // Until the fetch settles we don't yet know whether this viewer can read the // issue. Keep the mention a plain link (clickable, styled as today) but hold // off mounting the IssueLinkQuicklook hover preview — a Radix Popover portal — // until `data` confirms the issue is readable. // - Correctness (PAP-16070): a private mention 404s straight to the locked // chip. Mounting the quicklook popover during the loading `` only to // tear the portal down and swap in a plain `` chip on the 404 is the // element churn that crashed the chat transcript's primary renderer (it // fell through to the safe fallback). Loading link → chip stays portal-free. // - Privacy: don't prefetch / hover-preview an issue of unconfirmed // readability. `data-mention-pending` marks the transient state for tests. const pending = !data; return ( setEngaged(true)} onFocus={() => setEngaged(true)} // Boxless inline mention: the unified status glyph + a regular-weight // underlined link, optically centered with the body text. className={cn("paperclip-markdown-issue-ref", "font-normal underline")} title={title} aria-label={issueLabel} > {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 = externalObjectDisplayStatusLabel(reference); const livenessLabel = externalObjectLivenessLabel(reference.liveness); const livenessSuffix = reference.liveness === "fresh" || reference.liveness === "unknown" || livenessLabel.toLowerCase() === statusLabel.toLowerCase() ? "" : ` (${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 /^\\?