{children}
), strong: ({ children, ...props }) => ( {children} ), em: ({ children, ...props }) => ( {children} ), ul: ({ children, ...props }) => ({children}), hr: (props) =>
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
;
}
// 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 ? "…" : ""}
);
});