import { Children, createContext, useContext, isValidElement, memo, useEffect, useMemo, useReducer, useRef, useState, type ComponentPropsWithoutRef, type ReactNode, } from "react"; import ReactMarkdown, { defaultUrlTransform, type Components, type ExtraProps, type UrlTransform, } from "react-markdown"; import remarkGfm from "remark-gfm"; import { PanelsTopLeftIcon } from "lucide-react"; import { ActivityDisclosure } from "../timeline/shared"; import { cn } from "../lib/cn"; import { tableElementToTsv } from "../lib/clipboard"; import { prefersReducedMotion } from "../lib/motion"; import { MOTION_INSPECT_SCALE } from "../lib/motion-inspect"; import { CopyButton } from "./copy-button"; import { PreviewLoading } from "./preview-loading"; import type { observeMarkdownTableLayout } from "./markdown-table-layout"; import { remarkSoftLineBreaks } from "./remark-soft-line-breaks"; import { rehypeSecretRefs } from "./rehype-secret-refs"; import { mayContainSecretReference, SecretRefChip } from "./secret-ref-chip"; import { useSecretsUi } from "../secrets-ui"; import { softenStreamingMarkdown } from "./soften-streaming-markdown"; import { createStreamReveal, rehypeStreamReveal, type StreamReveal } from "./stream-reveal"; import { TooltipProvider } from "./tooltip"; import { searchMatchOffset, type TimelineSearchTarget } from "./timeline-search"; import { parseOpenGeniLink, parseSandboxLink, parseRetainedFileReference, isReservedOpenGeniLink, openGeniLinkScheme, type OpenGeniLinkTarget, } from "@opengeni/sdk"; import { chainLinkRenderers, chainLinkResolvers, useOpenGeniLinkRenderer, useOpenGeniLinkResolver, type OpenGeniLinkRenderer, type OpenGeniLinkResolver, } from "./open-geni-links"; import { rehypeStandaloneLinks, STANDALONE_LINK_ATTRIBUTE, STANDALONE_LINK_PROPERTY, } from "./rehype-standalone-links"; import { useOpenGeniText, type OpenGeniText } from "../messages/runtime"; /** * The default renderer for chat message bodies in {@link MessageTimeline}. * * Agent (and user) messages arrive as GitHub-flavored markdown. This turns the * raw text into styled HTML using `react-markdown` + `remark-gfm`, themed to the * package's `og-*` design tokens so it reads as one cohesive dark surface — no * stock Tailwind colors leak in. * * It re-parses on every render, which is exactly right for streaming: a body * that is still arriving (an unterminated `**`, a half-open code fence, a table * mid-row) renders as best-effort markdown and resolves cleanly as the rest of * the tokens land. Consumers who want a different renderer can still pass * `renderMessageText` to `MessageTimeline` to override this entirely. */ export type MarkdownInteractiveBlock = { kind: "html" | "site"; content: string }; const InteractiveContext = createContext<{ source: string; streaming?: boolean | undefined; render?: (block: MarkdownInteractiveBlock) => ReactNode; renderImage?: (image: { src: string; alt: string }) => ReactNode; suppressImages?: boolean; }>({ source: "" }); export type MarkdownProps = { /** Resolve a retained-file reference to the host's authenticated artifact page. */ artifactHref?: ((artifactId: string) => string) | undefined; /** Reveals a bounded source excerpt; closing Find retains it until explicit restore. */ searchTarget?: TimelineSearchTarget | null | undefined; renderImage?: ((image: { src: string; alt: string }) => ReactNode) | undefined; /** Show alt text without loading image URLs (e.g. in search previews). */ suppressImages?: boolean | undefined; /** Host opt-in for assistant-authored interactive fences. */ renderInteractiveBlock?: ((block: MarkdownInteractiveBlock) => ReactNode) | undefined; children: string; className?: string | undefined; /** * Render a single newline inside prose as a line break (`
`), the way chat * apps show messages; CommonMark otherwise folds it into a space. Chat message * bodies enable this. Leave it off for documents and other authored Markdown. * Code (fenced and inline), lists, tables, and blank-line paragraphs are * unaffected either way. */ softLineBreaks?: boolean | undefined; /** * While true, newly arrived source fades in through tip ink (`.og-stream-ink`): * an age window over each append batch — fast streams keep a large soft band, * slow streams a tight tip. Paint-only: the DOM always holds the full truthful * text. Once streaming ends and trailing ink settles, the body re-renders as * plain markdown with a short local settle breath (no page-wide view * transition — that blinked the whole document). */ streaming?: boolean | undefined; /** * Open a session-local file link in the host's Files surface. The path is the * exact decoded `sandbox:` payload; `line`, when present, is 1-based. */ onSandboxFile?: ((path: string, line?: number) => void | Promise) | undefined; /** * Resolve Opengeni object links (`artifact:`, `sandbox:`, editable * artifacts, Sites) to a host URL or action. Asked before any * {@link OpenGeniLinkProvider} above this body; `artifactHref` and * `onSandboxFile` still win for their own kinds. */ resolveLink?: OpenGeniLinkResolver | undefined; /** * Draw the host's own links (its scheme, in-app paths, web URLs) instead of * the default link. Asked before any `renderLink` on an * {@link OpenGeniLinkProvider} above this body. A link that is the only * content of its paragraph or list item arrives with `standalone: true`, and * that paragraph renders as a block so the host may return block content. */ renderLink?: OpenGeniLinkRenderer | undefined; }; /** Exact target path plus optional 1-based line from a markdown href. */ export type SandboxFileLocation = { path: string; line: number | null; }; /* --- element renderers (themed to og-* tokens) ------------------------------ */ const baseComponents: Components = { h1: ({ children, ...props }) => (

{children}

), h2: ({ children, ...props }) => (

{children}

), h3: ({ children, ...props }) => (

{children}

), h4: ({ children, ...props }) => (

{children}

), p: ({ children, ...props }) => (

{children}

), strong: ({ children, ...props }) => ( {children} ), em: ({ children, ...props }) => ( {children} ), ul: ({ children, ...props }) => ( ), ol: ({ children, ...props }) => (
    {children}
), // GFM task-list items carry a leading checkbox ; `list-none` + a // negative margin pull the checkbox back to the bullet column so it aligns // with the text. li: ({ children, ...props }) => (
  • {children}
  • ), input: ({ type, ...props }) => type === "checkbox" ? ( ) : ( ), blockquote: ({ children, ...props }) => (
    {children}
    ), hr: (props) =>
    , // Inline `code` vs fenced code blocks. react-markdown v10 no longer passes an // `inline` flag; a fenced block is a whose parent is
     (styled by
      // the `pre` renderer), so a `code` reaching here is treated as inline.
      code: ({ children, className: _className, ...props }) => (
        
          {children}
        
      ),
      // Fenced code — quiet mono block (no card / no nested vertical scroll).
      pre: InteractiveCodeBlock,
      // Tables stay unboxed (hairline rules); hover reveals a TSV copy control.
      table: ({ children, ...props }) => {children},
      thead: ({ children, ...props }) => {children},
      th: ({ children, ...props }) => (
        
          {children}
        
      ),
      td: ({ children, ...props }) => (
        
          {children}
        
      ),
      img: MarkdownImage,
    };
    
    const MARKDOWN_LINK_CLASS = "break-words font-medium og-link";
    
    // Keep renderer component types stable: rebuilding them remounts paragraphs,
    // destroys native selections, and resets embedded media on host updates.
    const MarkdownLinkContext = createContext<
      Pick
    >({});
    
    const UNAVAILABLE_LABEL = (
      t: OpenGeniText,
    ): Record, string> => ({
      file: t("markdown.thisFileNeedsAWorkspace"),
      "editable-artifact": t("markdown.thisArtifactNeedsAWorkspace"),
      site: t("markdown.thisSiteNeedsAWorkspace"),
    });
    
    function linkActionTitle(t: OpenGeniText, target: OpenGeniLinkTarget): string {
      if (target.kind === "sandbox-file") {
        return target.line !== null
          ? t("markdown.openPathAtLineLine", { path: target.path, line: target.line })
          : t("markdown.openPath", { path: target.path });
      }
      return target.kind === "file"
        ? t("markdown.openFile")
        : target.kind === "site"
          ? t("markdown.openSite")
          : t("markdown.openArtifact");
    }
    
    /** A `rehypeSecretRefs` placeholder becomes a chip; every other span passes through. */
    function MarkdownSpan({
      node: _node,
      children,
      ...props
    }: ComponentPropsWithoutRef<"span"> & ExtraProps) {
      const raw = (props as Record)["data-og-secret-ref"];
      if (typeof raw === "string") return {children};
      return {children};
    }
    
    function MarkdownSecretRef({ raw, children }: { raw: string; children?: ReactNode }) {
      const ui = useSecretsUi();
      const token = ui?.tokenize(raw).find((candidate) => candidate.type === "reference");
      if (!token || token.type !== "reference") return <>{children};
      return ;
    }
    
    // Module-level so react-markdown keeps stable component identities across
    // renders; renderers that need host text read it through the hook.
    const markdownComponents: Components = {
      ...baseComponents,
      span: MarkdownSpan,
      p: ({ children, node, ...props }) => {
        // Retained images and host-drawn standalone links may be block content.
        const containsBlock = node?.children.some(
          (child) =>
            child.type === "element" &&
            ((child.tagName === "img" &&
              typeof child.properties.src === "string" &&
              retainedImageId(child.properties.src)) ||
              (child.tagName === "a" && child.properties[STANDALONE_LINK_PROPERTY] !== undefined)),
        );
        const Tag = containsBlock ? "div" : "p";
        return (
          
            {children}
          
        );
      },
      a: ({ children, href, ...linkProps }) => {
        const t = useOpenGeniText();
        const { onSandboxFile, artifactHref, resolveLink, renderLink } =
          useContext(MarkdownLinkContext);
        const inherited = useOpenGeniLinkResolver();
        const { [STANDALONE_LINK_ATTRIBUTE]: standaloneMarker, ...props } =
          linkProps as typeof linkProps & Record;
        const target = parseOpenGeniLink(href);
        if (target) {
          // Explicit per-kind props keep their historical precedence.
          if (target.kind === "file" && target.workspaceId === null && artifactHref) {
            const destination = defaultUrlTransform(artifactHref(target.fileId));
            if (destination) {
              return (
                
                  {children}
                
              );
            }
          }
          const resolution =
            target.kind === "sandbox-file" && onSandboxFile
              ? { open: () => onSandboxFile(target.path, target.line ?? undefined) }
              : chainLinkResolvers(resolveLink, inherited)?.(target);
          const destination = resolution?.href ? defaultUrlTransform(resolution.href) : "";
          if (destination) {
            return (
              
                {children}
              
            );
          }
          if (resolution?.open) {
            return (
              
                {children}
              
            );
          }
          if (target.kind === "sandbox-file") {
            return (
              
                {children}
              
            );
          }
          // Never navigate: a console path resolves against the host origin and 404s.
          return (
            
              {children} {t("markdown.artifactUnavailable")}
            
          );
        }
        if (!isReservedOpenGeniLink(href) && href && renderLink) {
          const drawn = renderLink({ href, children, standalone: standaloneMarker !== undefined });
          if (drawn !== undefined) return <>{drawn};
        }
        // The URL transform keeps every authored href for the host renderer, so
        // the default link sanitizes here.
        const safeHref = href ? defaultUrlTransform(href) : "";
        if (isReservedOpenGeniLink(href) || !safeHref) {
          return (
            
              {children} {t("markdown.linkUnavailable")}
            
          );
        }
        return (
          
            {children}
          
        );
      },
    };
    
    function ActionMarkdownLink({
      title,
      onOpen,
      children,
    }: {
      title: string;
      onOpen: () => void | Promise;
      children: ReactNode;
    }) {
      const t = useOpenGeniText();
      const [state, setState] = useState<"idle" | "loading" | "error">("idle");
      return (
        
      );
    }
    
    function isSandboxHref(href: string | undefined): boolean {
      return openGeniLinkScheme(href) === "sandbox";
    }
    
    /**
     * Parse an Opengeni `sandbox:` application href (plus the historical bare
     * `/workspace/...` form). The decoded path is intentionally opaque here:
     * target selection, path policy, and filesystem authority belong to the
     * session-aware host and its FileSystem boundary.
     */
    export function sandboxFileLocationFromHref(href: string | undefined): SandboxFileLocation | null {
      return parseSandboxLink(href);
    }
    
    /** Return the exact decoded path for a supported sandbox-link href. */
    export function sandboxFilePathFromHref(href: string | undefined): string | null {
      return sandboxFileLocationFromHref(href)?.path ?? null;
    }
    
    export function retainedImageId(src: string): string | null {
      return parseRetainedFileReference(src);
    }
    function MarkdownImage({ src, alt }: ComponentPropsWithoutRef<"img">) {
      const t = useOpenGeniText();
      const { renderImage, suppressImages } = useContext(InteractiveContext);
      if (suppressImages)
        return (
          
            {alt || t("markdown.image")} {t("markdown.previewUnavailable")}
          
        );
      if (!src) return {alt ?? t("markdown.imageUnavailable")};
      if (retainedImageId(src))
        return renderImage ? (
          <>{renderImage({ src, alt: alt ?? "" })}
        ) : (
          
            {alt || t("markdown.image")} {t("markdown.previewUnavailable")}
          
        );
      return {alt;
    }
    
    // Link hrefs stay as authored: Opengeni object links and host renderers need
    // the exact destination, and the `a` renderer sanitizes the default link.
    const markdownUrlTransform: UrlTransform = (url, key, node) =>
      (key === "href" && node.tagName === "a") ||
      (key === "src" && node.tagName === "img" && retainedImageId(url))
        ? url
        : defaultUrlTransform(url);
    
    /** How long after the stream ends the reveal pipeline stays for trailing animations. */
    /** Trailing ink window after stream end — keep ≥ {@link INK_FADE_MS}. */
    const REVEAL_LINGER_MS = 480 * MOTION_INSPECT_SCALE;
    /** Keep in sync with `--og-duration-markdown-crystallize` / view-transition CSS. */
    const MARKDOWN_SETTLE_MS = 480 * MOTION_INSPECT_SCALE;
    
    function nodeText(node: ReactNode): string {
      if (node == null || typeof node === "boolean") {
        return "";
      }
      if (typeof node === "string" || typeof node === "number") {
        return String(node);
      }
      if (Array.isArray(node)) {
        return node.map(nodeText).join("");
      }
      if (isValidElement<{ children?: ReactNode }>(node)) {
        return nodeText(node.props.children);
      }
      return "";
    }
    
    function fenceLanguage(children: ReactNode): string | null {
      let found: string | null = null;
      Children.forEach(children, (child) => {
        if (found || !isValidElement<{ className?: string }>(child)) {
          return;
        }
        const match = /language-([a-zA-Z0-9_+-]+)/.exec(child.props.className ?? "");
        if (match?.[1]) {
          found = match[1];
        }
      });
      return found;
    }
    
    function InteractiveCodeBlock({ children, node }: ComponentPropsWithoutRef<"pre"> & ExtraProps) {
      const t = useOpenGeniText();
      const { source, render, streaming } = useContext(InteractiveContext);
      const language = fenceLanguage(children);
      if (render && (language === "opengeni-html" || language === "opengeni-site")) {
        const start = node?.position?.start.offset;
        const end = node?.position?.end.offset;
        const original =
          start !== undefined && end !== undefined && end <= source.length
            ? source.slice(start, end)
            : "";
        const lines = original.trimEnd().split("\n");
        const opening = lines[0]?.match(/^ {0,3}([\x60]{3,}|~{3,})/);
    
        const closing = lines
          .at(-1)
          ?.replace(/^\s*(?:>\s*)+/, "")
          .trim();
        const complete =
          opening &&
          lines.length > 1 &&
          closing &&
          closing.length >= opening[1]!.length &&
          [...closing].every((c) => c === opening[1]![0]);
        if (!complete) {
          return (
            
    {streaming ? ( ) : ( } title={t("markdown.previewIncomplete")} running={false} expandable={false} preview={t("markdown.generationStoppedBeforeThePreview")} /> )}
    ); } return ( <> {render({ kind: language === "opengeni-html" ? "html" : "site", content: nodeText(children).replace(/\n$/, ""), })} ); } return {children}; } function MarkdownCodeBlock({ children }: { children?: ReactNode }) { const t = useOpenGeniText(); const code = nodeText(children).replace(/\n$/, ""); const language = fenceLanguage(children); // mt-only (no mb / last:mb-0): next sibling streaming in used to flip a // previous block's bottom margin on and yank tip-follow. Copy is overlay-only. // Fixed leading + overflow-x:auto with stable block chrome so wide ASCII lines // don't pop a scrollbar gutters mid-stream (another tip bob). return (
    {language ? ( {language} ) : null}
            {children}
          
    ); } function MarkdownTable({ children, className, ...props }: ComponentPropsWithoutRef<"table">) { const t = useOpenGeniText(); const wrapperRef = useRef(null); const tableRef = useRef(null); const layoutRef = useRef>(undefined); useEffect(() => { const wrapper = wrapperRef.current; const table = tableRef.current; if (!wrapper?.closest("[data-og-wide-table-message]") || !table) return; let disposed = false; // Ordinary tables remain usable even if this optional layout chunk fails. void import("./markdown-table-layout") .then(({ observeMarkdownTableLayout }) => { if (!disposed) layoutRef.current = observeMarkdownTableLayout(wrapper, table); }) .catch(() => {}); return () => { disposed = true; layoutRef.current?.disconnect(); layoutRef.current = undefined; }; }, []); // ReactMarkdown recreates table children even when only later prose changes. // Remeasure genuine content changes without collapsing the expanded wrapper // between cleanup and the asynchronous observer-module import on each update. useEffect(() => { layoutRef.current?.measure(); }, [children]); return ( // Touch shows the copy action permanently at full touch size, so it sits // under the table there instead of covering the right-aligned header.
    tableElementToTsv(tableRef.current)} label={t("markdown.copyTable")} reveal="group-hover" />
    &`) under the scoped // selector list needs `:is()`, which bundlers warn about for older targets. "w-full min-w-0 border-collapse text-og-base [&_tr:last-child>td]:border-b-0", className, )} {...props} > {children}
    ); } type RemarkPlugins = NonNullable[0]["remarkPlugins"]>; const REMARK_PLUGINS: RemarkPlugins = [remarkGfm]; // Structural plugin types (see remark-soft-line-breaks.ts) need one cast at the seam. const SOFT_LINE_BREAK_REMARK_PLUGINS = [ remarkGfm, remarkSoftLineBreaks, ] as unknown as RemarkPlugins; function MarkdownImpl({ children, artifactHref, className, softLineBreaks = false, streaming = false, onSandboxFile, renderInteractiveBlock, renderImage, resolveLink, renderLink, suppressImages, }: MarkdownProps) { // Tip-ink engine for THIS body: created on the first streaming render, kept // through a short linger after the stream ends (so the last age window can // finish), then dropped so settled bodies pay zero cost. Observing during // render is idempotent per text length — StrictMode double-renders are safe. const revealRef = useRef(null); const bodyRef = useRef(null); const [, bump] = useReducer((n: number) => n + 1, 0); const [settling, setSettling] = useState(false); const hadRevealRef = useRef(false); const now = typeof performance !== "undefined" ? performance.now() : Date.now(); // Local settle breath only — View Transitions with `::view-transition-*(*)` // were fading the document root and felt like a full-page blink / focus loss // right as a turn ended (just before the next user message in the seed loop). const crystallize = (mutate: () => void) => { mutate(); setSettling(true); bump(); return setTimeout(() => setSettling(false), MARKDOWN_SETTLE_MS); }; if (streaming && revealRef.current === null && !prefersReducedMotion()) { revealRef.current = createStreamReveal(); } const reveal = revealRef.current; if (reveal !== null && streaming) { reveal.observe(children, now); } if (reveal !== null) { hadRevealRef.current = true; } const revealActive = reveal !== null && (streaming || reveal.hasActive(now)); const secretsUi = useSecretsUi(); const inheritedRenderer = useOpenGeniLinkRenderer(); const linkRenderer = useMemo( () => chainLinkRenderers(renderLink, inheritedRenderer) ?? undefined, [renderLink, inheritedRenderer], ); // Secret references become chip placeholders before the ink split, so a // reference is never cut into ink spans. Without a host tokenizer, or when // the text names no reference, the pipeline is unchanged. const secretRefs = secretsUi && mayContainSecretReference(children) ? [[rehypeSecretRefs, { tokenize: secretsUi.tokenize }]] : []; // The plugins walk the standard hast tree; structural local types keep the // modules dependency-free, at the cost of this narrowing cast at the seam. const rehypePluginList = [ ...(linkRenderer ? [rehypeStandaloneLinks] : []), ...secretRefs, ...(revealActive && reveal !== null ? [[rehypeStreamReveal, { reveal, now }]] : []), ]; const rehypePlugins = rehypePluginList.length > 0 ? (rehypePluginList as unknown as NonNullable< Parameters[0]["rehypePlugins"] >) : undefined; // Drop tip ink after trailing age windows finish. Same commit crystallizes: // ink spans → final plain markdown (soften off) + a short local settle breath. useEffect(() => { if (streaming || revealRef.current === null) { return; } let clearSettle: ReturnType | undefined; const timer = setTimeout(() => { const shouldSettle = hadRevealRef.current && !prefersReducedMotion(); if (shouldSettle) { hadRevealRef.current = false; } if (shouldSettle) { clearSettle = crystallize(() => { revealRef.current = null; }); } else { revealRef.current = null; bump(); } }, REVEAL_LINGER_MS); return () => { clearTimeout(timer); if (clearSettle !== undefined) { clearTimeout(clearSettle); } }; }, [streaming]); // No-reveal path (stream ended before any ink batch): soften already dropped // with `streaming`; keep the same local settle breath. const wasStreamingRef = useRef(streaming); useEffect(() => { const ended = wasStreamingRef.current && !streaming; wasStreamingRef.current = streaming; if (!ended || revealRef.current !== null || prefersReducedMotion()) { return; } setSettling(true); const timer = setTimeout(() => setSettling(false), MARKDOWN_SETTLE_MS); return () => clearTimeout(timer); }, [streaming]); // Soften unfinished markers for DISPLAY while the reveal pipeline is alive // (stream + linger) — not only while `streaming` — so task lists / tables / // fences don't snap to final GFM one commit before the crystallize morph. // Reveal identity still tracks the true source (`children`). const parseText = streaming || revealActive ? softenStreamingMarkdown(children) : children; const linkContext = useMemo( () => ({ onSandboxFile, artifactHref, resolveLink, renderLink: linkRenderer }), [onSandboxFile, artifactHref, resolveLink, linkRenderer], ); const interactiveContext = useMemo( () => ({ source: children, streaming, ...(renderInteractiveBlock ? { render: renderInteractiveBlock } : {}), ...(renderImage ? { renderImage } : {}), ...(suppressImages ? { suppressImages } : {}), }), [children, streaming, renderInteractiveBlock, renderImage, suppressImages], ); // `min-w-0` lets the prose shrink inside flex parents (message bubbles) so // long links and code blocks wrap/scroll instead of forcing overflow. return (
    {parseText}
    ); } /** Memoized so streaming re-renders of the parent don't re-parse settled bodies. */ export const Markdown = memo(function SearchableMarkdown(props: MarkdownProps) { const t = useOpenGeniText(); const [retained, setRetained] = useState<{ text: string; target: TimelineSearchTarget } | null>( null, ); if ( props.searchTarget && (retained?.target !== props.searchTarget || retained.text !== props.children) ) { setRetained({ text: props.children, target: props.searchTarget }); } // Closing Find only removes the highlight. Retain the source window so a huge // formatted message cannot replace it and unexpectedly move the reading point. const target = props.searchTarget ?? (retained?.text === props.children ? retained.target : null); if (!target) return ; const offset = searchMatchOffset(props.children, target); if (offset < 0) return props.searchTarget ? (
    {t("markdown.thisMatchIsNoLonger")}
    ) : ( ); // Bound mounted text even for multi-megabyte messages. Do not split a UTF-16 pair // at excerpt edges; the selected range itself is validated against the source. let start = Math.max(0, offset - 240); let end = Math.min(props.children.length, offset + target.query.length + 240); if (start > 0 && /[\uDC00-\uDFFF]/.test(props.children[start]!)) start--; if (end < props.children.length && /[\uDC00-\uDFFF]/.test(props.children[end]!)) end++; const MatchTag = props.searchTarget ? "mark" : "span"; return (
    {t("markdown.matchInMessageSourceExcerpt")}
    {start > 0 ? "…" : ""} {props.children.slice(start, offset)} {props.children.slice(offset, offset + target.query.length)} {props.children.slice(offset + target.query.length, end)} {end < props.children.length ? "…" : ""}
    ); });