import { useAtomValue } from "@effect/atom-react"; import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import { CheckIcon, ChevronRightIcon, CopyIcon, GlobeIcon, Maximize2Icon, Minimize2Icon, 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 { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; 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 { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; 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, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, } 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 { isPreviewSupportedInRuntime } from "../previewStateStore"; import { isBrowserPreviewFile, openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, { hasError: boolean } > { constructor(props: { fallback: ReactNode; children: ReactNode }) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } override render() { if (this.state.hasError) { return this.props.fallback; } return this.props.children; } } 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; } 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, ); const highlighterPromiseCache = new Map>(); 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]); } const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"], }, protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file"], }, } satisfies Parameters[0]; const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, remarkNormalizeListItemIndentation, remarkBreaks, remarkPreserveCodeMeta, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; 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); }; } 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 }>(onlyChild) || onlyChild.type !== "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 getHighlighterPromise(language: string): Promise { const cached = highlighterPromiseCache.get(language); if (cached) return cached; const promise = getSharedHighlighter({ themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], langs: [language as SupportedLanguages], preferredHighlighter: "shiki-js", }).catch((err) => { highlighterPromiseCache.delete(language); if (language === "text") { // "text" itself failed — Shiki cannot initialize at all, surface the error throw err; } // Language not supported by Shiki — fall back to "text" return getHighlighterPromise("text"); }); highlighterPromiseCache.set(language, promise); return promise; } 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(getHighlighterPromise(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>; 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; } 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(); function resolveExternalLinkHost(href: string | undefined): string | null { if (!href) return null; try { const url = new URL(href); if (url.protocol !== "http:" && url.protocol !== "https:") return null; return url.hostname || null; } catch { return null; } } 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; } 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, 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; } useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line); }, [handleOpenInEditor, line, 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.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className ); } function ChatMarkdown({ text, cwd, threadRef, onTaskListChange, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { 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 fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); return buildFileLinkParentSuffixByPath(filePaths); }, [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 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 }); }, [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], ); const markdownComponents = useMemo( () => ({ p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; return (
  • {renderSkillInlineMarkdownChildren(children, skills)}
  • ); }, 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, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { const faviconHost = resolveExternalLinkHost(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); } }} onContextMenu={(event) => { if (!canOpenInPreview || !href) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; void (async () => { let operation = "show-link-context-menu"; try { const clicked = await api.contextMenu.show( [ { id: "open-in-browser", label: "Open in integrated browser" }, { id: "open-external", label: "Open in system browser" }, ] as const, { x: event.clientX, y: event.clientY }, ); if (clicked === "open-in-browser") { operation = "open-link-in-preview"; const result = await openExternalLinkInPreview(href); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { reportMarkdownActionFailure({ operation, target: href }, result.cause); } return; } if (clicked === "open-external") { operation = "open-link-external"; await api.shell.openExternal(href); } } catch (cause) { reportMarkdownActionFailure({ operation, target: href }, cause); } })(); }} > {faviconHost ? ( {children} ) : ( children )} ); if (!faviconHost || !href) { return link; } return ( {href} ); } 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={props.className} /> ); }, 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}}> ); }, }), [ diffThemeName, fileLinkParentSuffixByPath, isStreaming, markdownFileLinkMetaByHref, onTaskListChange, openInPreferredEditor, openExternalLinkInPreview, openMarkdownFileInPreview, resolvedTheme, skills, text, threadRef, ], ); return (
    {text}
    ); } export default memo(ChatMarkdown);