import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, ChevronRightIcon, CopyIcon, GlobeIcon, InfoIcon, LightbulbIcon, Maximize2Icon, MessageSquareWarningIcon, Minimize2Icon, OctagonAlertIcon, TriangleAlertIcon, WrapTextIcon, } from "lucide-react"; import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, Suspense, type ClipboardEvent as ReactClipboardEvent, type MouseEvent as ReactMouseEvent, isValidElement, use, useCallback, memo, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { remarkGithubAlerts } from "../markdown-github-alerts"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { resolveExternalWebLinkHost, showExternalLinkContextMenu, } from "./chat/externalLinkContextMenu"; import { hasSpecificPierreIconForFileName, syntheticFileNameForLanguageId } from "../pierre-icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Button } from "./ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsible"; import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, serializeTableElementToMarkdown, } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; import { useRightPanelStore } from "../rightPanelStore"; import { useActiveEnvironmentId } from "../state/entities"; import { serverEnvironment } from "../state/server"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { projectEnvironment } from "../state/projects"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { isBrowserPreviewFile, openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; /** Parse sanitized raw HTML instead of displaying its source text. */ parseRawHtml?: boolean; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; interface MarkdownActionFailureContext { readonly operation: string; readonly target?: string; readonly format?: "markdown" | "csv"; readonly language?: string; readonly fenceTitle?: string; readonly copyTarget?: string; } function reportMarkdownActionFailure(context: MarkdownActionFailureContext, cause: unknown): void { console.error("[chat-markdown] action failed", context, cause); } const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); const firstLine = markdown.slice( listItemStart, firstLineEnd === -1 ? markdown.length : firstLineEnd, ); const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); if (!match?.[1]) return null; return listItemStart + firstLine.indexOf(match[1]); } /** * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits two-digit * decimal markers. Once a list's last item reaches three digits (item 100+), * `list-style-position: outside` paints the marker wider than that gutter and * the leading digit gets clipped by the item's own overflow. Rather than * widening the gutter for every list, only lists whose last marker is 3+ * digits get a wider `--list-gutter`, sized to that marker's digit count. */ export function orderedListGutterStyle( itemCount: number, start: number | undefined, ): { "--list-gutter": string } | undefined { const firstNumber = typeof start === "number" && Number.isFinite(start) ? start : 1; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); const digits = String(Math.abs(lastNumber)).length; if (digits <= 2) return undefined; return { "--list-gutter": `${digits + 1}ch` }; } const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], }, protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file"], }, } satisfies Parameters[0]; const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, remarkTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, remarkTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ const GITHUB_ALERT_PRESENTATIONS: Record< string, { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } > = { note: { label: "Note", Icon: InfoIcon, borderClassName: "border-blue-500/70", titleClassName: "text-blue-600 dark:text-blue-400", }, tip: { label: "Tip", Icon: LightbulbIcon, borderClassName: "border-emerald-500/70", titleClassName: "text-emerald-600 dark:text-emerald-400", }, important: { label: "Important", Icon: MessageSquareWarningIcon, borderClassName: "border-purple-500/70", titleClassName: "text-purple-600 dark:text-purple-400", }, warning: { label: "Warning", Icon: TriangleAlertIcon, borderClassName: "border-amber-500/70", titleClassName: "text-amber-600 dark:text-amber-500", }, caution: { label: "Caution", Icon: OctagonAlertIcon, borderClassName: "border-red-500/70", titleClassName: "text-red-600 dark:text-red-400", }, }; function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); const raw = match?.[1] ?? "text"; // Shiki doesn't bundle a gitignore grammar; ini is a close match (#685) return raw === "gitignore" ? "ini" : raw; } const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i; const FENCE_FILENAME_TOKEN_REGEX = /^[\w@][\w@./-]*\.[A-Za-z0-9]+$/; /** Pulls a filename out of fence meta: ```ts title="x.ts" / ```ts src/main.ts */ function extractFenceTitle(meta: string | undefined): string | null { if (!meta) return null; const attrMatch = FENCE_TITLE_ATTR_REGEX.exec(meta); const attrTitle = attrMatch?.[1] ?? attrMatch?.[2] ?? attrMatch?.[3]; if (attrTitle) return attrTitle; return meta.split(/\s+/).find((candidate) => FENCE_FILENAME_TOKEN_REGEX.test(candidate)) ?? null; } function extractPreCodeMeta(node: unknown): string | undefined { const children = ( node as | { children?: Array<{ type?: string; tagName?: string; data?: { meta?: unknown }; properties?: { dataCodeMeta?: unknown }; }>; } | undefined )?.children; const codeNode = children?.find((child) => child?.type === "element" && child.tagName === "code"); const meta = codeNode?.properties?.dataCodeMeta ?? codeNode?.data?.meta; return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined; } type MarkdownAstNode = { type?: string; meta?: unknown; data?: { hProperties?: Record; }; children?: MarkdownAstNode[]; }; function remarkPreserveCodeMeta() { return (tree: MarkdownAstNode) => { const visit = (node: MarkdownAstNode) => { if (node.type === "code" && typeof node.meta === "string" && node.meta.trim().length > 0) { node.data = { ...node.data, hProperties: { ...node.data?.hProperties, dataCodeMeta: node.meta.trim(), }, }; } node.children?.forEach(visit); }; visit(tree); }; } /** * Fenced code also lands on the `code` component, and inline vs block is no * longer distinguishable there once both render `` — so inline spans are * tagged on the mdast, where the distinction still exists. Code inside a link * label stays untagged: linkifying it would nest an anchor inside the link's * anchor and steal its clicks. */ function remarkTagInlineCode() { return (tree: MarkdownAstNode) => { const visit = (node: MarkdownAstNode, insideLink: boolean) => { if (node.type === "inlineCode" && !insideLink) { node.data = { ...node.data, hProperties: { ...node.data?.hProperties, dataInlineCode: "", }, }; } const childInsideLink = insideLink || node.type === "link" || node.type === "linkReference"; node.children?.forEach((child) => visit(child, childInsideLink)); }; visit(tree, false); }; } function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); } if (Array.isArray(node)) { return node.map((child) => nodeToPlainText(child)).join(""); } if (isValidElement<{ children?: ReactNode }>(node)) { return nodeToPlainText(node.props.children); } return ""; } function extractCodeBlock( children: ReactNode, ): { className: string | undefined; code: string } | null { const childNodes = Children.toArray(children); if (childNodes.length !== 1) { return null; } const onlyChild = childNodes[0]; if ( !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( onlyChild, ) ) { return null; } // With a custom `code` component the child's type is that component, not // the "code" tag — the hast node react-markdown attaches still names it. if (onlyChild.type !== "code" && onlyChild.props.node?.tagName !== "code") { return null; } return { className: onlyChild.props.className, code: nodeToPlainText(onlyChild.props.children), }; } function createHighlightCacheKey(code: string, language: string, themeName: DiffThemeName): string { return `${fnv1a32(code).toString(36)}:${code.length}:${language}:${themeName}`; } function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap; } function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const containerRef = useRef(null); const tableRef = useRef(null); const [expanded, setExpanded] = useState(readInitialWordWrapSetting); const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | null>(null); const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; const copyLabel = copied ? "Copied" : "Copy table"; function toggleExpanded() { const table = tableRef.current; if (!table) return; if (!expanded) { const rows = [...table.rows]; const columnWidths = rows.reduce((widths, row) => { [...row.cells].forEach((cell, columnIndex) => { widths[columnIndex] = Math.max( widths[columnIndex] ?? 0, cell.getBoundingClientRect().width, ); }); return widths; }, []); [...(table.tHead?.rows[0]?.cells ?? [])].forEach((cell, columnIndex) => { cell.style.minWidth = `${columnWidths[columnIndex] ?? cell.getBoundingClientRect().width}px`; }); } setExpanded((value) => !value); } const handleCopy = useCallback((format: "markdown" | "csv") => { const table = containerRef.current?.querySelector("table"); if (!table || typeof navigator === "undefined" || navigator.clipboard == null) { return; } const text = format === "markdown" ? serializeTableElementToMarkdown(table) : serializeTableElementToCsv(table); void navigator.clipboard .writeText(text) .then(() => { if (copiedTimerRef.current != null) { clearTimeout(copiedTimerRef.current); } setCopied(true); copiedTimerRef.current = setTimeout(() => { setCopied(false); copiedTimerRef.current = null; }, 1200); }) .catch((cause) => { reportMarkdownActionFailure({ operation: "copy-table", format }, cause); }); }, []); useEffect( () => () => { if (copiedTimerRef.current != null) { clearTimeout(copiedTimerRef.current); copiedTimerRef.current = null; } }, [], ); return (
{children}
} > {expanded ? : } {expandLabel} } /> } > {copied ? : } {copyLabel} handleCopy("markdown")}>Copy as Markdown handleCopy("csv")}>Copy as CSV
); } function MarkdownDetails({ children, open = false, }: Pick, "children" | "open">) { const [isOpen, setIsOpen] = useState(open); const childNodes = Children.toArray(children); const summaryIndex = childNodes.findIndex( (child) => isValidElement(child) && child.type === "summary", ); const summaryNode = summaryIndex >= 0 ? childNodes[summaryIndex] : null; const summary = isValidElement<{ children?: ReactNode }>(summaryNode) && summaryNode.props.children ? summaryNode.props.children : "Details"; const content = childNodes.filter((_, index) => index !== summaryIndex); return ( {summary}
{content}
); } /** * Filename titles render icon + text; language-only titles render just the * icon (redundant next to its own name) and fall back to the language text * when no specific icon exists or it fails to load. */ function MarkdownCodeBlockTitleContent({ fenceTitle, language, theme, }: { fenceTitle: string | null; language: string; theme: "light" | "dark"; }) { if (fenceTitle) { return ( <> {fenceTitle} ); } const fileName = syntheticFileNameForLanguageId(language); if (!hasSpecificPierreIconForFileName(fileName)) { return {language}; } return ( } > {language} ); } function MarkdownCodeBlock({ code, language, fenceTitle, theme, children, }: { code: string; language: string; fenceTitle: string | null; theme: "light" | "dark"; children: ReactNode; }) { const [copied, setCopied] = useState(false); const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; const copyLabel = copied ? "Copied" : "Copy code"; const handleCopy = useCallback(() => { if (typeof navigator === "undefined" || navigator.clipboard == null) { return; } void navigator.clipboard .writeText(code) .then(() => { if (copiedTimerRef.current != null) { clearTimeout(copiedTimerRef.current); } setCopied(true); copiedTimerRef.current = setTimeout(() => { setCopied(false); copiedTimerRef.current = null; }, 1200); }) .catch((cause) => { reportMarkdownActionFailure( { operation: "copy-code-block", language, ...(fenceTitle ? { fenceTitle } : {}), }, cause, ); }); }, [code, fenceTitle, language]); useEffect( () => () => { if (copiedTimerRef.current != null) { clearTimeout(copiedTimerRef.current); copiedTimerRef.current = null; } }, [], ); return (
setWrapped((value) => !value)} aria-label={wrapLabel} /> } > {wrapLabel} } > {copied ? : } {copyLabel}
{children}
); } interface SuspenseShikiCodeBlockProps { className: string | undefined; code: string; themeName: DiffThemeName; isStreaming: boolean; } function SuspenseShikiCodeBlock({ className, code, themeName, isStreaming, }: SuspenseShikiCodeBlockProps) { const language = extractFenceLanguage(className); const cacheKey = createHighlightCacheKey(code, language, themeName); const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null; if (cachedHighlightedHtml != null) { return (
); } return ( ); } interface UncachedShikiCodeBlockProps { code: string; language: string; themeName: DiffThemeName; cacheKey: string; isStreaming: boolean; } function UncachedShikiCodeBlock({ code, language, themeName, cacheKey, isStreaming, }: UncachedShikiCodeBlockProps) { const highlighter = use(getSyntaxHighlighterPromise(language)); const highlightedHtml = useMemo(() => { try { return highlighter.codeToHtml(code, { lang: language, theme: themeName }); } catch (error) { // Log highlighting failures for debugging while falling back to plain text console.warn( `Code highlighting failed for language "${language}", falling back to plain text.`, error instanceof Error ? error.message : error, ); // If highlighting fails for this language, render as plain text return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); } }, [code, highlighter, language, themeName]); useEffect(() => { if (!isStreaming) { highlightedCodeCache.set( cacheKey, highlightedHtml, estimateHighlightedSize(highlightedHtml, code), ); } }, [cacheKey, code, highlightedHtml, isStreaming]); return (
); } interface MarkdownFileLinkProps { href: string; targetPath: string; iconPath: string; displayPath: string; workspaceRelativePath: string | null; line?: number | undefined; label: string; copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen: (targetPath: string) => Promise>; onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; onOpenInBrowser?: (() => Promise>) | undefined; className?: string | undefined; } const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; function pathParentSegments(path: string): string[] { const normalized = path.replaceAll("\\", "/"); const segments = normalized.split("/").filter((segment) => segment.length > 0); return segments.slice(0, -1); } function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map { const groups = new Map>(); for (const filePath of filePaths) { const pathSegments = filePath .replaceAll("\\", "/") .split("/") .filter((segment) => segment.length > 0); const basename = pathSegments[pathSegments.length - 1]; if (!basename) continue; const group = groups.get(basename) ?? new Set(); group.add(filePath); groups.set(basename, group); } const suffixByPath = new Map(); for (const group of groups.values()) { const uniquePaths = [...group]; if (uniquePaths.length < 2) continue; const parentSegmentsByPath = new Map( uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), ); const minUniqueDepthByPath = new Map(); for (const filePath of uniquePaths) { const segments = parentSegmentsByPath.get(filePath) ?? []; let resolvedDepth = segments.length; for (let depth = 1; depth <= segments.length; depth += 1) { const candidate = segments.slice(-depth).join("/"); const collision = uniquePaths.some((otherPath) => { if (otherPath === filePath) return false; const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; return otherSegments.slice(-depth).join("/") === candidate; }); if (!collision) { resolvedDepth = depth; break; } } minUniqueDepthByPath.set(filePath, resolvedDepth); } for (const filePath of uniquePaths) { const segments = parentSegmentsByPath.get(filePath) ?? []; if (segments.length === 0) continue; const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); } } return suffixByPath; } const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/; const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g; function extractInlineCodeSpans(text: string): string[] { const spans: string[] = []; const segments = text.split(FENCED_CODE_SEGMENT_PATTERN); for (let index = 0; index < segments.length; index += 2) { for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) { const span = match[1]?.trim(); if (span) spans.push(span); } } return spans; } function extractMarkdownLinkHrefs(text: string): string[] { const hrefs: string[] = []; for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { const href = match[1]?.trim(); if (!href) continue; hrefs.push(href); } return hrefs; } function normalizeMarkdownLinkHrefKey(href: string): string { const normalizedHref = normalizeMarkdownLinkDestination(href); return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; } const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; /** Hosts whose favicon request already failed this session — skip straight to the globe. */ const failedFaviconHosts = new Set(); const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); return ( {failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( { failedFaviconHosts.add(host); setFailedHost(host); }} /> )} ); }); function leadingExternalLinkTextLength(text: string): number { const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0]; if (protocol) return protocol.length; return Math.min(text.length, 1); } function breakableExternalLinkText(text: string): ReactNode[] { return Array.from(text, (character, index) => ( {character} )); } function plainHastText(node: unknown): string | null { if (!node || typeof node !== "object" || !("children" in node) || !Array.isArray(node.children)) { return null; } const parts = node.children.map((child) => { if ( child && typeof child === "object" && "type" in child && child.type === "text" && "value" in child && typeof child.value === "string" ) { return child.value; } return null; }); return parts.every((part) => part !== null) ? parts.join("") : null; } /** * Whether the link carries any words of its own. An anchor that is only an image — a badge, a * "Fix in Cursor" button — already shows its identity, and a favicon bolted on in front of it * is a stray logo rather than a hint. */ function hastHasText(node: unknown): boolean { if (!node || typeof node !== "object") return false; if ( "type" in node && node.type === "text" && "value" in node && typeof node.value === "string" && node.value.trim().length > 0 ) { return true; } return "children" in node && Array.isArray(node.children) && node.children.some(hastHasText); } const SANITIZED_FRAGMENT_PREFIX = "user-content-"; function decodeMarkdownFragmentId(href: string): string { const encodedId = href.slice(1); try { return decodeURIComponent(encodedId); } catch { return encodedId; } } function normalizeSanitizedFragmentId(id: string): string { let normalizedId = id; while (normalizedId.startsWith(SANITIZED_FRAGMENT_PREFIX)) { normalizedId = normalizedId.slice(SANITIZED_FRAGMENT_PREFIX.length); } return normalizedId; } function findMarkdownFragmentTarget(anchor: HTMLAnchorElement, href: string): HTMLElement | null { const decodedId = decodeMarkdownFragmentId(href); const normalizedId = normalizeSanitizedFragmentId(decodedId); const matchesFragment = (element: HTMLElement) => element.id === decodedId || normalizeSanitizedFragmentId(element.id) === normalizedId; const markdownRoot = anchor.closest(".chat-markdown"); if (markdownRoot) { const localTargets = Array.from(markdownRoot.querySelectorAll("[id]")); const localTarget = localTargets.find(matchesFragment); if (localTarget) return localTarget; } return ( document.getElementById(decodedId) ?? Array.from(document.querySelectorAll("[id]")).find(matchesFragment) ?? null ); } function handleMarkdownFragmentClick(event: ReactMouseEvent, href: string) { if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) { return; } const target = findMarkdownFragmentTarget(event.currentTarget, href); if (!target) return; event.preventDefault(); const nextUrl = new URL(window.location.href); nextUrl.hash = href.slice(1); window.history.pushState(window.history.state, "", nextUrl); target.scrollIntoView({ block: "nearest" }); } function MarkdownExternalLinkContent({ host, plainText, children, }: { host: string; plainText: string | null; children: ReactNode; }) { if (plainText) { const leadingLength = leadingExternalLinkTextLength(plainText); return ( <> {plainText.slice(0, leadingLength)} {breakableExternalLinkText(plainText.slice(leadingLength))} ); } const childNodes = Children.toArray(children); const firstChild = childNodes[0]; if (typeof firstChild === "string" && firstChild.length > 0) { const leadingLength = leadingExternalLinkTextLength(firstChild); return ( <> {firstChild.slice(0, leadingLength)} {breakableExternalLinkText(firstChild.slice(leadingLength))} {childNodes.slice(1)} ); } return ( <> {firstChild} {childNodes.slice(1)} ); } const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, iconPath, displayPath, workspaceRelativePath, line, label, copyMarkdown, theme, threadRef, onOpen, onOpenInPanel, onOpenInBrowser, className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { void (async () => { try { const result = await onOpen(targetPath); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } reportMarkdownActionFailure( { operation: "open-file-in-editor", target: targetPath }, result.cause, ); const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open file", description: error instanceof Error ? error.message : "An error occurred.", }), ); } catch (cause) { reportMarkdownActionFailure( { operation: "open-file-in-editor", target: targetPath }, cause, ); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open file", description: cause instanceof Error ? cause.message : "An error occurred.", }), ); } })(); }, [onOpen, targetPath]); const handleOpenInFilePreview = useCallback(() => { if (!threadRef || !workspaceRelativePath) { handleOpenInEditor(); return; } onOpenInPanel(workspaceRelativePath, line); }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { return; } void (async () => { try { const result = await onOpenInBrowser(); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } reportMarkdownActionFailure( { operation: "open-file-in-browser", target: targetPath }, result.cause, ); const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open file in browser", description: error instanceof Error ? error.message : "An error occurred.", }), ); } catch (cause) { reportMarkdownActionFailure( { operation: "open-file-in-browser", target: targetPath }, cause, ); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open file in browser", description: cause instanceof Error ? cause.message : "An error occurred.", }), ); } })(); }, [onOpenInBrowser, targetPath]); const handleCopy = useCallback( (value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { toastManager.add( stackedThreadToast({ type: "error", title: `Failed to copy ${title.toLowerCase()}`, description: "Clipboard API unavailable.", }), ); return; } void navigator.clipboard.writeText(value).then( () => { toastManager.add({ type: "success", title: `${title} copied`, description: value, }); }, (error) => { reportMarkdownActionFailure( { operation: "copy-file-path", target: targetPath, copyTarget: title }, error, ); toastManager.add( stackedThreadToast({ type: "error", title: `Failed to copy ${title.toLowerCase()}`, description: error instanceof Error ? error.message : "An error occurred.", }), ); }, ); }, [targetPath], ); const handleContextMenu = useCallback( async (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; try { const clicked = await api.contextMenu.show( [ { id: "open", label: "Open in editor" }, ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, ] as const, { x: event.clientX, y: event.clientY }, ); if (clicked === "open") { handleOpenInEditor(); return; } if (clicked === "open-in-browser") { handleOpenInBrowser(); return; } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; } if (clicked === "copy-full") { handleCopy(targetPath, "Full path"); } } catch (cause) { reportMarkdownActionFailure( { operation: "show-file-context-menu", target: targetPath }, cause, ); } }, [displayPath, handleCopy, handleOpenInBrowser, handleOpenInEditor, onOpenInBrowser, targetPath], ); return ( { event.preventDefault(); event.stopPropagation(); if (onOpenInBrowser) { handleOpenInBrowser(); return; } handleOpenInFilePreview(); }} onContextMenu={handleContextMenu} > } />
{displayPath}
); }, areMarkdownFileLinkPropsEqual); function areMarkdownFileLinkPropsEqual( previous: Readonly, next: Readonly, ): boolean { return ( previous.href === next.href && previous.targetPath === next.targetPath && previous.iconPath === next.iconPath && previous.displayPath === next.displayPath && previous.workspaceRelativePath === next.workspaceRelativePath && previous.line === next.line && previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && previous.theme === next.theme && previous.threadRef === next.threadRef && previous.onOpen === next.onOpen && previous.onOpenInPanel === next.onOpenInPanel && previous.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className ); } function ChatMarkdown({ text, cwd, threadRef, onTaskListChange, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, parseRawHtml = true, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); const environmentId = useActiveEnvironmentId(); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( environmentId, serverConfig?.availableEditors ?? [], ); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< string, NonNullable> >(); for (const href of extractMarkdownLinkHrefs(text)) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); if (meta) { metaByHref.set(normalizedHref, meta); } } return metaByHref; }, [cwd, text]); const inlineCodeFileLinkMetaByText = useMemo(() => { const metaByText = new Map(); for (const span of extractInlineCodeSpans(text)) { if (metaByText.has(span)) continue; const meta = resolveInlineCodeFileLinkMeta(span, cwd); if (meta) { metaByText.set(span, meta); } } return metaByText; }, [cwd, text]); const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [ ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), ]; return buildFileLinkParentSuffixByPath(filePaths); }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); // Re-emit highlighted content as markdown so copying out of the rendered // view keeps links, emphasis, lists, and code fences intact. const handleCopy = useCallback((event: ReactClipboardEvent) => { const selection = window.getSelection(); if (!selection || selection.isCollapsed || !event.clipboardData) return; const payload = chatMarkdownClipboardPayload(selection); if (!payload) return; event.preventDefault(); event.clipboardData.setData("text/plain", payload.text); event.clipboardData.setData("text/html", payload.html); }, []); const openChangeRequestLink = useOpenChangeRequestLink(threadRef); const openExternalLinkInPreview = useCallback( (url: string) => { if (!threadRef) { return Promise.resolve( AsyncResult.failure( Cause.fail( new BrowserPreviewUnavailableError({ message: "Thread context is unavailable.", }), ), ), ); } return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { if (result._tag === "Success") recordVisitForThread(threadRef, url); return result; }); }, [openPreview, threadRef], ); const openMarkdownFileInPreview = useCallback( (path: string) => { if (!threadRef || preparedConnection._tag === "None") { return Promise.resolve( AsyncResult.failure( Cause.fail( new BrowserPreviewUnavailableError({ message: "Environment is not connected.", }), ), ), ); } return openFileInPreview({ threadRef, filePath: path, httpBaseUrl: preparedConnection.value.httpBaseUrl, createAssetUrl, openPreview, }); }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); // A bare filename resolves to the workspace root, which is rarely where the // file is, so ask the index before opening. const openFileInPanel = useCallback( (workspaceRelativePath: string, line: number | undefined) => { if (!threadRef) return; // Claimed on every open so a synchronous one supersedes a lookup already // in flight. const isLatestLookup = claimWorkspaceBasenameLookup(); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line); if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { openAt(workspaceRelativePath); return; } void (async () => { const result = await searchProjectEntries({ environmentId: threadRef.environmentId, input: { cwd, query: workspaceRelativePath, limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, kind: "file", }, }); const match = result._tag === "Success" ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) : null; if (!isLatestLookup()) return; openAt(match ?? workspaceRelativePath); })(); }, [cwd, searchProjectEntries, threadRef], ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that * metadata changes. */ const markdownComponents = useMemo(() => { const fileLinkChip = ( fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, className?: string, ) => { const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); const labelParts = [fileLinkMeta.basename]; if (typeof parentSuffix === "string" && parentSuffix.length > 0) { labelParts.push(parentSuffix); } if (fileLinkMeta.line) { labelParts.push( `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, ); } return ( openMarkdownFileInPreview(fileLinkMeta.filePath) : undefined } className={className} /> ); }; return { p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, blockquote({ node: _node, children, ...props }) { const alert = GITHUB_ALERT_PRESENTATIONS[ String((props as Record)["data-alert"] ?? "") ]; if (!alert) { return
{children}
; } // Not a
: the stylesheet mutes those, and an alert's body is ordinary // text under a colored title — which is how the host renders it. return (

{alert.label}

{children}
); }, ol({ node, start, style, ...props }) { const itemCount = node?.children?.filter((child) => child.type === "element" && child.tagName === "li") .length ?? 0; const gutterStyle = orderedListGutterStyle(itemCount, start); return (
    ); }, li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; return (
  1. {renderSkillInlineMarkdownChildren(children, skills)}
  2. ); }, input({ node: _node, type, checked, disabled: _disabled, ...props }) { if (type !== "checkbox" || !onTaskListChange) { return ( ); } return ( { const markerOffset = Number( event.currentTarget.closest("li")?.dataset.taskMarkerOffset, ); if (!Number.isSafeInteger(markerOffset)) return; onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); }} /> ); }, a({ node, href, children, title: _title, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); const link = ( { onClick?.(event); if (isSameDocumentLink && href) { handleMarkdownFragmentClick(event, href); return; } // A link to a change request in a workspace project opens beside the // conversation instead of in a browser: it is the thing being talked about, and // the panel it opens offers the browser as one of its actions. Anything else is // an ordinary link and keeps the `_blank` the shell already handles. if (href) openChangeRequestLink(event, href); }} onContextMenu={(event) => { if (!href || !faviconHost) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; void showExternalLinkContextMenu({ href, canOpenInPreview, position: { x: event.clientX, y: event.clientY }, showContextMenu: (items, position) => api.contextMenu.show(items, position), openInPreview: async (target) => { const result = await openExternalLinkInPreview(target); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { reportMarkdownActionFailure( { operation: "open-link-in-preview", target }, result.cause, ); } }, openExternal: (target) => api.shell.openExternal(target), copyLink: (target) => writeTextToClipboard(target, "link"), reportFailure: (operation, cause) => { reportMarkdownActionFailure({ operation, target: href }, cause); }, }); }} > {faviconHost && hastHasText(node) ? ( {children} ) : ( children )} ); if (!faviconHost || !href) { return link; } return ( {href} ); } return fileLinkChip( fileLinkMeta, `[${fileLinkMeta.basename}](${normalizedHref})`, props.className, ); }, img({ node: _node, title: _title, ...props }) { return ; }, code({ node, children, className, ...props }) { if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); const fileLinkMeta = inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? resolveInlineCodeFileLinkMeta(codeText, cwd); if (fileLinkMeta) { return fileLinkChip(fileLinkMeta, `\`${codeText}\``); } } return ( {children} ); }, table({ node: _node, ...props }) { return ; }, details({ node: _node, children, open: detailsOpen }) { return {children}; }, pre({ node, children, ...props }) { const codeBlock = extractCodeBlock(children); if (!codeBlock) { return
    {children}
    ; } const language = extractFenceLanguage(codeBlock.className); const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); return ( {children}}> {children}}> ); }, }; }, [ cwd, diffThemeName, fileLinkParentSuffixByPath, inlineCodeFileLinkMetaByText, isStreaming, markdownFileLinkMetaByHref, onTaskListChange, openFileInPanel, openInPreferredEditor, openExternalLinkInPreview, openMarkdownFileInPreview, resolvedTheme, skills, text, threadRef, ]); /* eslint-enable react/no-unstable-nested-components */ // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the // complete source token instead of dropping it from the rendered message. return (
    {text}
    ); } export default memo(ChatMarkdown);