import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, ChevronRightIcon, CopyIcon, FileSpreadsheetIcon, FileTextIcon, GlobeIcon, ImageIcon, InfoIcon, LightbulbIcon, MailIcon, Maximize2Icon, MessageSquareIcon, MessageSquareWarningIcon, Minimize2Icon, OctagonAlertIcon, PresentationIcon, SparklesIcon, TriangleAlertIcon, WrapTextIcon, type LucideIcon, } from "lucide-react"; import type { AssetResource, EnvironmentId, ScopedThreadRef, ServerProviderSkill, ThreadPullRequestKey, } from "@t3tools/contracts"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { isAtomCommandInterrupted, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { codexArtifactTemplatePresentationLabel, type CodexArtifactTemplate, type CodexArtifactTemplateKind, } from "@t3tools/client-runtime/codex-artifact-templates"; import { classifyMarkdownImageSource, markdownImageSourceFragment, } from "@t3tools/client-runtime/markdown-images"; import { inlineCodeFilePathCandidate } from "@t3tools/client-runtime/markdown-links"; import { mediaFileReference, mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, Suspense, type CSSProperties, type ComponentProps, type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, 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 { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { AssistantCitationChip } from "./chat/AssistantCitationChip"; import remarkGfm from "remark-gfm"; import { remarkGithubAlerts } from "../markdown-github-alerts"; import { artifactTemplateFromHastProperties, CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, remarkCodexDirectives, renderCodexFileCitationsAsMarkdown, } from "@t3tools/client-runtime/codex-markdown-directives"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; import { resolveMarkdownMediaPreview, type ExpandedImagePreview, } from "./chat/ExpandedImagePreview"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { markdownImageGallery, markdownImageItems } from "./chat/markdownImageGallery"; import { MediaVideoPlayer } from "./media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { revealInFileExplorerLabelForKind, revealInFileExplorerLabelForOs, } from "./preview/fileExplorerLabel"; 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 { PreferredEditorEnvironmentRequiredError, useOpenInPreferredEditor, usePreferredEditor, } from "../editorPreferences"; import { openInEditorMenuLabel } from "../editorLabels"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { GitHubIcon } from "./Icons"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, serializeTableElementToMarkdown, } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { extractMarkdownLinkHrefs, isWindowsDrivePathHref, normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; import { shellEnvironment } from "../state/shell"; 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 { findProjectForChangeRequest, parseChangeRequestUrl, pullRequestCandidateUrlFromReferenceAutolink, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError, BrowserSettingsReadError, } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; /** Panel that receives pull request links, including the standalone PR view. */ pullRequestPanelRef?: ScopedThreadRef | undefined; /** Environment that owns non-thread markdown, such as a pull request panel. */ environmentId?: EnvironmentId | 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; /** Append a prompt that invokes a newly created artifact-template skill. */ onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; /** Directory that anchors relative links and images; defaults to `cwd`. Set to the file's own directory when rendering a markdown file. */ imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; extraRemarkPlugins?: NonNullable; } export function canUseMarkdownFileShellActions( environmentId: EnvironmentId | null, remoteOpenMode: RemoteOpenMode, isRemoteOpenResolved: boolean, ): boolean { return environmentId !== null && isRemoteOpenResolved && remoteOpenMode === "local-exec"; } export function hasMarkdownFilePrimaryAction(input: { canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean; canOpenMedia?: boolean; }): boolean { return ( input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel || input.canOpenMedia === true ); } export function shouldUseMarkdownFileBrowserPrimaryAction(input: { iconPath: string; canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean; }): boolean { return ( input.canOpenInBrowser && (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || (!input.canOpenInEditor && !input.canOpenInPanel)) ); } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; const EMPTY_REMARK_PLUGINS: NonNullable = []; const ARTIFACT_TEMPLATE_ICON_BY_KIND = { document: FileTextIcon, presentation: PresentationIcon, spreadsheet: FileSpreadsheetIcon, site: GlobeIcon, "google-docs": FileTextIcon, "google-slides": PresentationIcon, "google-sheets": FileSpreadsheetIcon, image: ImageIcon, email: MailIcon, slack: MessageSquareIcon, } satisfies Record; function CodexArtifactTemplateCard(props: { readonly template: CodexArtifactTemplate; readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined; }) { const Icon = ARTIFACT_TEMPLATE_ICON_BY_KIND[props.template.artifactKind]; const presentationLabel = codexArtifactTemplatePresentationLabel(props.template.artifactKind); return (
{props.template.displayName} {presentationLabel}
{props.onUse ? ( ) : null}
); } const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; 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 one-character * markers. Wider markers can extend past it and get clipped by a collapsed * message's overflow. Widen the gutter to fit the widest marker, including a * negative marker's minus sign. */ function orderedListGutterStyle( itemCount: number, start: unknown, ): { "--list-gutter": string } | undefined { const parsedStart = Number.parseInt(String(start ?? 1), 10); const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); if (markerWidth <= 1) return undefined; return { "--list-gutter": `${markerWidth + 1}ch` }; } type MarkdownImageHastNode = { type?: string; tagName?: string; properties?: Record; children?: MarkdownImageHastNode[]; }; function meaningfulHastChildren(node: MarkdownImageHastNode): MarkdownImageHastNode[] { return (node.children ?? []).filter( (child) => !(child.type === "text" && (child as { value?: string }).value?.trim() === ""), ); } /** * An image that is the only content of its block (optionally wrapped in a * link) is almost always a screenshot or figure, so it gets a reserved slot * while it loads. Images mixed with text or other images — badge rows, icons * in a sentence — stay inline at their natural size, since a placeholder taller * than the image would move the page more than the image itself does. */ /** Containers whose sole child image reads as a figure rather than part of a sentence. */ const STANDALONE_IMAGE_BLOCKS = new Set([ "p", "div", "li", "td", "th", "figure", "center", "blockquote", ]); function soleImageDescendant(node: MarkdownImageHastNode): MarkdownImageHastNode | undefined { const children = meaningfulHastChildren(node); if (children.length !== 1) return undefined; const only = children[0]; if (only?.type !== "element") return undefined; if (only.tagName === "img") return only; // A link, emphasis, or similar inline wrapper around the image still counts // as long as nothing else shares the block. return only.tagName === "a" || only.tagName === "strong" || only.tagName === "em" ? soleImageDescendant(only) : undefined; } function markStandaloneImages(node: MarkdownImageHastNode) { // A raw `` on its own line reaches the root without a paragraph. if (node.type === "root" || (node.tagName && STANDALONE_IMAGE_BLOCKS.has(node.tagName))) { const image = soleImageDescendant(node); if (image) image.properties = { ...image.properties, dataStandalone: true }; } node.children?.forEach((child) => { if (child.type === "element") markStandaloneImages(child); }); } /** Carries authored image source metadata through the sanitizer to the image renderer. */ function rehypePreserveImageSourceMeta() { return (tree: MarkdownImageHastNode) => { const visit = (node: MarkdownImageHastNode) => { const src = node.properties?.src; const title = node.properties?.title; if (node.type === "element" && node.tagName === "img") { node.properties = { ...node.properties, ...(typeof src === "string" && isWindowsDrivePathHref(src) ? { dataLocalSrc: src } : {}), ...(typeof title === "string" ? { dataMarkdownTitle: title } : {}), }; } node.children?.forEach(visit); }; visit(tree); markStandaloneImages(tree); }; } 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"], div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], img: [ ...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle", "dataStandalone", ], }, protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file", "t3-citation"], src: [...(defaultSchema.protocols?.src ?? []), "file"], }, } satisfies Parameters[0]; const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkCodexDirectives, remarkPreserveCodeMeta, remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkGfm, remarkGithubAlerts, remarkNormalizeListItemIndentation, remarkCodexDirectives, remarkBreaks, remarkPreserveCodeMeta, remarkNormalizeLinksAndTagInlineCode, ] satisfies NonNullable; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, rehypePreserveImageSourceMeta, [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; url?: string; 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); }; } /** * Preserve Windows drive links as allowed `file:` URLs before sanitization. * The same traversal tags inline code while it can still be distinguished * from fenced code. Code inside links stays untagged to avoid nested anchors. */ function remarkNormalizeLinksAndTagInlineCode() { return (tree: MarkdownAstNode) => { const visit = (node: MarkdownAstNode, insideLink: boolean) => { if ( (node.type === "link" || node.type === "definition") && typeof node.url === "string" && WINDOWS_DRIVE_PATH_REGEX.test(node.url) ) { node.url = `file:///${node.url.replaceAll("\\", "/")}`; } 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; /** What the files panel opens: workspace-relative inside the workspace, the absolute host path outside it, null when the panel cannot show the file. */ panelPath: string | null; line?: number | undefined; label: string; copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen?: ((targetPath: string) => Promise>) | undefined; onOpenInPanel: (panelPath: string, line: number | undefined) => void; openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; onOpenMedia?: (() => void) | undefined; onReveal?: (() => Promise>) | undefined; /** Platform-specific menu label ("Reveal in Finder", ...); required for the reveal item to show. */ revealLabel?: string | undefined; className?: string | undefined; } const MARKDOWN_FILE_CHIP_CLASS_NAME = "chat-markdown-file-link"; const MARKDOWN_FILE_LINK_CLASS_NAME = `${MARKDOWN_FILE_CHIP_CLASS_NAME} cursor-pointer transition-colors hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/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 normalizedPath = filePath.replaceAll("\\", "/"); const pathSegments = normalizedPath.split("/").filter((segment) => segment.length > 0); const basename = pathSegments[pathSegments.length - 1]; if (!basename) continue; const group = groups.get(basename) ?? new Set(); group.add(normalizedPath); 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 normalizeMarkdownLinkHrefKey(href: string): string { const normalizedHref = normalizeMarkdownLinkDestination(href); const rewrittenHref = rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; return WINDOWS_DRIVE_PATH_REGEX.test(rewrittenHref) ? rewrittenHref.replaceAll("\\", "/") : rewrittenHref; } 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(); /** Sites whose brand mark (drawn in `currentColor`) replaces the fetched favicon so it follows the theme. */ function brandLinkIcon(host: string): typeof GitHubIcon | null { const hostname = host.toLowerCase(); if (hostname === "github.com" || hostname.endsWith(".github.com")) return GitHubIcon; return null; } const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); const BrandIcon = brandLinkIcon(host); const faviconUrl = BrandIcon ? null : faviconUrlForOrigin(`https://${host}`); return ( {BrandIcon ? ( ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( { failedFaviconHosts.add(host); setFailedHost(host); }} /> )} ); }); const CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME = "max-w-[min(100%,30rem)]"; const CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME = cn( "max-h-[30rem]", CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, ); const CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME = "inline-block!"; const CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME = "rounded-lg border border-border/40"; const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( "h-auto w-auto object-contain", CHAT_MARKDOWN_MEDIA_BOUNDS_CLASS_NAME, ); function markdownImageCopy(alt: string, src: string, title: string | undefined): string { const escapedAlt = alt.replaceAll("\\", "\\\\").replaceAll("[", "\\[").replaceAll("]", "\\]"); const titleSuffix = title === undefined ? "" : ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; return `![${escapedAlt}](${src}${titleSuffix})`; } /** * `maxHeightRem` folds a height cap into the width bound: `max-height` alone * would not feed back through `aspect-ratio` once `width` is definite, so a * tall image would keep a box wider than the picture it draws. */ function authoredImageSizeStyle( width: string | number | undefined, height: string | number | undefined, maxHeightRem = 30, ): CSSProperties | undefined { const parsedWidth = Number(width); const parsedHeight = Number(height); const hasWidth = Number.isFinite(parsedWidth) && parsedWidth > 0; const hasHeight = Number.isFinite(parsedHeight) && parsedHeight > 0; if (hasWidth && hasHeight) { return { width: parsedWidth, height: "auto", aspectRatio: `${parsedWidth} / ${parsedHeight}`, maxWidth: `min(100%, 30rem, ${(maxHeightRem * parsedWidth) / parsedHeight}rem)`, }; } if (hasWidth) return { maxWidth: `min(100%, 30rem, ${parsedWidth}px)` }; if (hasHeight) return { maxHeight: `min(30rem, ${parsedHeight}px)` }; return undefined; } const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( CHAT_MARKDOWN_MEDIA_LAYOUT_CLASS_NAME, CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, ); const MarkdownLinkContext = React.createContext(false); function expandableMarkdownImageProps( onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, alt: string, ) { if (!onImageExpand) return {}; const previewName = alt.trim() || "image"; const expand = (event: ReactMouseEvent | ReactKeyboardEvent) => { if (event.currentTarget.closest("a")) return; event.preventDefault(); event.stopPropagation(); const item = markdownImageItems.get(event.currentTarget); if (item) onImageExpand(markdownImageGallery(event.currentTarget, item)); }; return { role: "button" as const, tabIndex: 0, "aria-label": `Preview ${previewName}`, onClick: expand, onKeyDown: (event: ReactKeyboardEvent) => { if (event.key === "Enter" || event.key === " ") expand(event); }, }; } function ChatMarkdownMediaUnavailableLabel(props: { readonly alt: string; readonly kind?: "image" | "video" | undefined; }) { const label = props.kind === "video" ? "Video unavailable" : "Image unavailable"; return ( {props.alt.length > 0 ? `${label} · ${props.alt}` : label} ); } /** Inline chip for an image that sits in a line of text or can never load. */ function ChatMarkdownImageFallback(props: { readonly alt: string; readonly copyMarkdown?: string | undefined; readonly kind?: "image" | "video"; readonly actionsSource?: MediaActionSource | undefined; }) { const content = ( ); return props.actionsSource ? ( {content} ) : ( content ); } const CHAT_MARKDOWN_IMAGE_FRAME_CLASS_NAME = cn( "aspect-video w-full overflow-hidden bg-muted/60", CHAT_MARKDOWN_MEDIA_MAX_WIDTH_CLASS_NAME, CHAT_MARKDOWN_MEDIA_FRAME_CLASS_NAME, ); /** * A standalone image holds a 16:9 slot (or its authored size) until it has * decoded, and keeps that slot if it fails, so a timeline row moves at most * once: when the natural size arrives. A bare `` is zero height until * then. Once decoded the image renders bare again so its box, hit area, and * alignment are exactly the image's own. Inline images (badges, icons in a * sentence) skip the slot: a placeholder taller than the image would move the * page more than the image does. * * Callers key this on the file's identity, not its URL: a re-signed URL for * the same file keeps the decoded image on screen while the new bytes arrive, * and a different file starts from the slot again. */ function ChatMarkdownImage(props: { /** Null while the URL is being resolved; the last decoded image stays up. */ readonly src: string | null; readonly sourceFailed?: boolean | undefined; readonly alt: string; readonly copyMarkdown: string | undefined; readonly standalone: boolean; readonly className?: string | undefined; readonly style?: CSSProperties | undefined; /** Sanitized authored attributes (`id`, `align`, …) that fragment links and layout rely on. */ readonly imageProps?: | Omit, "src" | "alt" | "className" | "style"> | undefined; readonly actionsSource: MediaActionSource; readonly originalUrl?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { const [loadedSrc, setLoadedSrc] = useState(null); const [failedSrc, setFailedSrc] = useState(null); const src = props.src ?? loadedSrc; const failed = props.sourceFailed === true || (src !== null && failedSrc === src); // A failure forgets the decoded image so the next URL loads behind the slot. const settled = src !== null && !failed && (!props.standalone || loadedSrc !== null); // Cached images are complete before `onLoad` can fire. const markLoadedIfComplete = useCallback( (image: HTMLImageElement | null) => { if (!image) return; if (image.complete && image.naturalWidth > 0) setLoadedSrc(image.currentSrc || image.src); markdownImageItems.set(image, { src, name: props.alt.trim() || "image", actionsSource: props.actionsSource, ...(props.originalUrl ? { originalUrl: props.originalUrl } : {}), }); }, [props.actionsSource, props.alt, props.originalUrl, src], ); const imageEvents = (loadingSrc: string) => ({ onLoad: () => { setLoadedSrc(loadingSrc); setFailedSrc(null); }, onError: () => { setFailedSrc(loadingSrc); setLoadedSrc(null); }, }); if (settled) { return ( {props.alt} ); } if (!props.standalone) { return failed ? ( ) : ( ); } return ( {failed ? ( ) : src !== null ? ( {props.alt} ) : null} ); } function ChatMarkdownVideo(props: { readonly src: string | null; readonly alt: string; readonly copyMarkdown: string | undefined; readonly originalUrl?: string | undefined; readonly sourceFailed?: boolean | undefined; readonly style?: CSSProperties | undefined; readonly mediaIdentity?: string | undefined; readonly actionsSource?: MediaActionSource | undefined; readonly onRetry?: (() => Promise) | undefined; }) { return ( ); } /** Environment-hosted media loads through an exact-file signed asset URL. */ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: { readonly environmentId: EnvironmentId; readonly resource: Extract< AssetResource, { readonly _tag: "attachment" | "workspace-file" | "media-file" } >; readonly kind?: "image" | "video"; readonly alt: string; readonly copyMarkdown?: string; readonly srcFragment?: string; /** Reserve a slot while loading; off for images that share a line with text. */ readonly standalone?: boolean | undefined; /** Caps the box height in rem while keeping the image's ratio; 30 by default. */ readonly maxHeightRem?: number | undefined; readonly style?: CSSProperties | undefined; readonly workspaceRoot?: string | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { const assetUrl = useAssetUrlState(props.environmentId, props.resource); const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, props.resource); const resource = props.resource; const path = resource._tag === "media-file" ? resource.path : resource._tag === "workspace-file" && props.workspaceRoot ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}` : undefined; const reference = path ? mediaFileReference(path, props.workspaceRoot) : undefined; const relativePath = reference?.relativePath; const src = assetUrl._tag === "Success" ? assetUrl.url + (props.srcFragment ?? "") : null; // The server reads the pixel size from the file header, so the slot can be // the image's final box instead of a 16:9 guess. An authored size wins; a // caller's height cap shrinks the box while keeping the ratio. const knownSize = assetUrl._tag === "Success" ? assetUrl.imageDimensions : undefined; const maxHeightRem = props.maxHeightRem ?? 30; const style = props.style ?? (knownSize ? authoredImageSizeStyle(knownSize.width, knownSize.height, maxHeightRem) : maxHeightRem !== 30 ? { maxHeight: `${maxHeightRem}rem` } : undefined); const actionsSource: MediaActionSource = { kind: props.kind ?? "image", name: props.alt || (props.kind ?? "image"), src, asset: { environmentId: props.environmentId, resource }, ...(reference ? { reference } : {}), ...(relativePath && resource._tag !== "attachment" ? { onOpenFile: () => useRightPanelStore .getState() .openFile( { environmentId: props.environmentId, threadId: resource.threadId }, relativePath, ), } : {}), }; if (props.kind === "video") { return ( ); } return ( ); }); 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, panelPath, line, label, copyMarkdown, theme, threadRef, onOpen, onOpenInPanel, openInEditorMenuLabel, onOpenInBrowser, onOpenMedia, onReveal, revealLabel, className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { if (!onOpen) { return; } 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 && panelPath) { onOpenInPanel(panelPath, line); return; } if (onOpenMedia) { onOpenMedia(); return; } handleOpenInEditor(); }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, panelPath, threadRef]); 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 handleRevealInFileManager = useCallback(() => { if (!onReveal) { return; } void (async () => { try { const result = await onReveal(); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } reportMarkdownActionFailure( { operation: "reveal-file-in-file-manager", target: targetPath }, result.cause, ); const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to reveal file", description: error instanceof Error ? error.message : "An error occurred.", }), ); } catch (cause) { reportMarkdownActionFailure( { operation: "reveal-file-in-file-manager", target: targetPath }, cause, ); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to reveal file", description: cause instanceof Error ? cause.message : "An error occurred.", }), ); } })(); }, [onReveal, 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 showFileContextMenu = useCallback( async (position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; try { const clicked = await api.contextMenu.show( [ ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, ] as const, position, ); if (clicked === "preview-media") { onOpenMedia?.(); return; } if (clicked === "open") { handleOpenInEditor(); return; } if (clicked === "open-in-browser") { handleOpenInBrowser(); return; } if (clicked === "reveal") { handleRevealInFileManager(); 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, handleRevealInFileManager, onOpenInBrowser, onOpenMedia, onOpen, onReveal, openInEditorMenuLabel, revealLabel, targetPath, ], ); const handleContextMenu = useCallback( (event: ReactMouseEvent) => { event.preventDefault(); event.stopPropagation(); const position = event.clientX === 0 && event.clientY === 0 ? (() => { const bounds = event.currentTarget.getBoundingClientRect(); return { x: bounds.left, y: bounds.bottom }; })() : { x: event.clientX, y: event.clientY }; void showFileContextMenu(position); }, [showFileContextMenu], ); const canOpenInEditor = onOpen !== undefined; const canOpenInBrowser = onOpenInBrowser !== undefined; const canOpenInPanel = threadRef !== undefined && Boolean(panelPath); const hasPrimaryAction = hasMarkdownFilePrimaryAction({ canOpenInEditor, canOpenInBrowser, canOpenInPanel, canOpenMedia: onOpenMedia !== undefined, }); const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ iconPath, canOpenInEditor, canOpenInBrowser, canOpenInPanel, }); return ( { event.preventDefault(); event.stopPropagation(); if (onOpen && shouldOpenMarkdownFileLinkInEditor(event)) { handleOpenInEditor(); return; } if (useBrowserPrimaryAction) { handleOpenInBrowser(); return; } handleOpenInFilePreview(); }} onContextMenu={handleContextMenu} > ) : ( ) } /> {/* The full path: the chip already shows the shortened form, and a link to the workspace root collapses to a bare label that repeats it. */}
{targetPath}
); }, 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.panelPath === next.panelPath && 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.openInEditorMenuLabel === next.openInEditorMenuLabel && previous.onOpenInBrowser === next.onOpenInBrowser && previous.onOpenMedia === next.onOpenMedia && previous.onReveal === next.onReveal && previous.revealLabel === next.revealLabel && previous.className === next.className ); } function useChatMarkdownState({ text, cwd, threadRef, pullRequestPanelRef, environmentId: explicitEnvironmentId, onTaskListChange, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, onUseArtifactTemplate, imageBaseDir, onImageExpand, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); const markdownRef = useRef(null); const expandMedia = onImageExpand ?? setLocalMediaPreview; const mediaRequestId = useRef(0); useEffect(() => { setLocalMediaPreview(null); return () => { mediaRequestId.current += 1; }; }, [threadRef?.environmentId, threadRef?.threadId, explicitEnvironmentId, cwd, imageBaseDir]); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, }); const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); const pullRequestLinking = usePullRequestLinking(threadRef?.environmentId); const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; const remoteOpen = useRemoteOpenResolution(environmentId); const canUseShellActions = canUseMarkdownFileShellActions( environmentId, remoteOpen.state.mode, remoteOpen.isResolved, ); const preparedConnection = usePreparedConnection(environmentId); const openMarkdownMedia = useCallback( (source: string, resolvedFilePath?: string, clickedImage?: HTMLImageElement | null) => { const requestId = ++mediaRequestId.current; void resolveMarkdownMediaPreview({ source, resolvedFilePath, cwd, threadRef, httpBaseUrl: preparedConnection._tag === "Some" ? preparedConnection.value.httpBaseUrl : undefined, createAssetUrl, onOpenFile: threadRef ? (path) => useRightPanelStore.getState().openFile(threadRef, path) : undefined, }).then( (preview) => { if (preview && mediaRequestId.current === requestId) { const selected = preview.images[preview.index]; expandMedia( selected && selected.type !== "video" && markdownRef.current ? markdownImageGallery(clickedImage ?? markdownRef.current, selected) : preview, ); } }, (error: unknown) => { if (mediaRequestId.current !== requestId) return; toastManager.add( stackedThreadToast({ type: "error", title: "Media unavailable", description: error instanceof Error ? error.message : "The file could not be loaded. It may have been moved or deleted.", }), ); }, ); }, [createAssetUrl, cwd, expandMedia, preparedConnection, threadRef], ); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const projects = useProjects(); const availableEditors = serverConfig?.availableEditors ?? []; const [preferredEditor] = usePreferredEditor(availableEditors); const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { reportFailure: false, }); const revealInFileManagerLabel = environmentId !== null && serverConfig?.shellRevealInFileManager === true && serverConfig.availableEditors.includes("file-manager") ? serverConfig.shellRevealInFileManagerKind === undefined ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os) : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind) : undefined; const revealFileInFileManager = useCallback( (filePath: string) => { if (environmentId === null) { return Promise.resolve( AsyncResult.failure( Cause.fail(new PreferredEditorEnvironmentRequiredError({ targetPath: filePath })), ), ); } return openInEditor({ environmentId, input: { cwd: filePath, editor: "file-manager", reveal: true }, }); }, [environmentId, openInEditor], ); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< string, NonNullable> >(); for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd); if (meta) { metaByHref.set(normalizedHref, meta); } } return metaByHref; }, [cwd, imageBaseDir, text]); const inlineCodeFileLinkMetaByText = useMemo(() => { const metaByText = new Map(); for (const span of extractInlineCodeSpans(text)) { if (metaByText.has(span)) continue; const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); if (meta) { metaByText.set(span, meta); } } return metaByText; }, [cwd, imageBaseDir, 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) => { if (parseAssistantCitationHref(href)) return href; if (isWindowsDrivePathHref(href)) return href; 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, pullRequestPanelRef); const openDeferredMarkdownLink = useOpenLink(threadRef); // Subscribed rather than read at click time: the anchor has to decide // synchronously whether to intercept its `_blank`, and a subscription is what // makes a persisted "app" apply once settings hydrate after launch. const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); const resolveThreadPullRequest = useCallback( (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => { if ( threadRef === undefined || readThreadShell(threadRef) === null || !pullRequestLinking.canLink(href) ) return null; const parsed = parseChangeRequestUrl(href); return parsed === null ? null : { ...parsed, url: href }; }, [pullRequestLinking, threadRef], ); const linkedThreadPullRequestFor = useCallback( (href: string) => { if (threadRef === undefined || !pullRequestLinking.isLinked(readThreadShell(threadRef), href)) return null; const parsed = parseChangeRequestUrl(href); return parsed === null ? null : { ...parsed, url: href }; }, [pullRequestLinking, threadRef], ); const updateThreadPullRequestLink = useCallback( async (href: string, linked: boolean) => { if (threadRef === undefined || (!linked && linkedThreadPullRequestFor(href) === null)) return; await pullRequestLinking.changeLink(threadRef, href, linked); }, [linkedThreadPullRequestFor, pullRequestLinking, 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); else if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); if (error instanceof BrowserSettingsReadError) { toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open link in browser", description: error.message, }), ); } } 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, workspaceRoot: cwd, httpBaseUrl: preparedConnection.value.httpBaseUrl, createAssetUrl, openPreview, }); }, [createAssetUrl, cwd, openPreview, preparedConnection, threadRef], ); const findWorkspaceBasenameMatch = useCallback( async (workspaceRelativePath: string) => { if (!cwd || environmentId === null || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { return null; } const result = await searchProjectEntries({ environmentId, input: { cwd, query: workspaceRelativePath, limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, kind: "file", }, }); return result._tag === "Success" ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) : null; }, [cwd, environmentId, searchProjectEntries], ); // A bare filename resolves to the workspace root, which is rarely where the // file is, so ask the index before opening. Absolute host paths open as-is. const openFileInPanel = useCallback( (panelPath: 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(panelPath)) { openAt(panelPath); return; } void (async () => { const match = await findWorkspaceBasenameMatch(panelPath); if (!isLatestLookup()) return; openAt(match ?? panelPath); })(); }, [cwd, findWorkspaceBasenameMatch, threadRef], ); const revealMarkdownFileInFileManager = useCallback( async (fileLinkMeta: MarkdownFileLinkMeta) => { const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; const match = workspaceRelativePath ? await findWorkspaceBasenameMatch(workspaceRelativePath) : null; const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; return revealFileInFileManager(filePath); }, [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], ); const fileLinkChip = useCallback( ( fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, className?: string, mediaSource?: string, ) => { const parentSuffix = fileLinkParentSuffixByPath.get( fileLinkMeta.filePath.replaceAll("\\", "/"), ); 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}` : ""}`, ); } const mediaPath = mediaSource ?? fileLinkMeta.filePath; const canPreviewMedia = mediaMimeTypeFromExtension( fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")), ) !== null; // Media outside the workspace keeps the expanded preview; other host // files (a report in a temp dir) open read-only in the files panel. const panelPath = fileLinkMeta.workspaceRelativePath ?? (!canPreviewMedia && isAbsolutePath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null); return ( openMarkdownMedia(mediaPath, fileLinkMeta.filePath) : undefined } openInEditorMenuLabel={preferredEditorMenuLabel} onReveal={ canUseShellActions && revealInFileManagerLabel !== undefined ? () => revealMarkdownFileInFileManager(fileLinkMeta) : undefined } revealLabel={revealInFileManagerLabel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && isBrowserPreviewFile(fileLinkMeta.filePath) ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) : undefined } className={className} /> ); }, [ canUseShellActions, fileLinkParentSuffixByPath, openFileInPanel, openInPreferredEditor, openMarkdownFileInPreview, openMarkdownMedia, preferredEditorMenuLabel, resolvedTheme, revealInFileManagerLabel, revealMarkdownFileInFileManager, threadRef, ], ); const componentState = useMemo( () => ({ cwd, diffThemeName, environmentId, expandMedia, fileLinkChip, imageBaseDir, inlineCodeFileLinkMetaByText, isStreaming, linkTargetPreference, markdownFileLinkMetaByHref, onTaskListChange, onUseArtifactTemplate, openChangeRequestLink, openDeferredMarkdownLink, openExternalLinkInPreview, openMarkdownMedia, projects, linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, serverConfig, skills, text, threadRef, updateThreadPullRequestLink, }), [ cwd, diffThemeName, environmentId, expandMedia, fileLinkChip, imageBaseDir, inlineCodeFileLinkMetaByText, isStreaming, linkTargetPreference, markdownFileLinkMetaByHref, onTaskListChange, onUseArtifactTemplate, openChangeRequestLink, openDeferredMarkdownLink, openExternalLinkInPreview, openMarkdownMedia, projects, linkedThreadPullRequestFor, resolveThreadPullRequest, resolvedTheme, serverConfig, skills, text, threadRef, updateThreadPullRequestLink, ], ); return { componentState, handleCopy, markdownRef, markdownUrlTransform, localMediaPreview, setLocalMediaPreview, }; } const ChatMarkdownRendererContext = React.createContext< ReturnType["componentState"] >(null!); // Keep component types stable when streaming changes the message state. const CHAT_MARKDOWN_COMPONENTS = { div: function MarkdownDiv({ node, children, ...props }) { const { onUseArtifactTemplate } = use(ChatMarkdownRendererContext); const artifactTemplate = artifactTemplateFromHastProperties(node?.properties); if (artifactTemplate) { return ( ); } return
{children}
; }, p: function MarkdownParagraph({ node: _node, children, ...props }) { const { skills } = use(ChatMarkdownRendererContext); return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, blockquote: function MarkdownBlockquote({ 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: function MarkdownOrderedList({ 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: function MarkdownListItem({ node, children, ...props }) { const { text, skills } = use(ChatMarkdownRendererContext); const listItemStart = node?.position?.start.offset; const markerOffset = typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; return (
  1. {renderSkillInlineMarkdownChildren(children, skills)}
  2. ); }, input: function MarkdownInput({ node: _node, type, checked, disabled: _disabled, ...props }) { const { onTaskListChange } = use(ChatMarkdownRendererContext); 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: function MarkdownAnchor({ node, href, children, title: _title, ...props }) { const { cwd, environmentId, imageBaseDir, markdownFileLinkMetaByHref, threadRef, openMarkdownMedia, openChangeRequestLink, openDeferredMarkdownLink, linkTargetPreference, openExternalLinkInPreview, projects, linkedThreadPullRequestFor, resolveThreadPullRequest, serverConfig, updateThreadPullRequestLink, fileLinkChip, } = use(ChatMarkdownRendererContext); const citation = href ? parseAssistantCitationHref(href) : null; if (citation) return ; const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd)) : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); const pullRequestAutolink = String( (props as Record)["data-pull-request-autolink"] ?? "", ); const pullRequestCopy = pullRequestAutolink === "commit" ? /\/commit\/([0-9a-f]{40})$/iu.exec(href ?? "")?.[1] : pullRequestAutolink === "reference" ? plainHastText(node) : undefined; const isPullRequestAutolink = pullRequestCopy !== undefined; const confirmBeforeOpen = pullRequestAutolink === "reference"; const pullRequestCandidateUrl = confirmBeforeOpen && href ? pullRequestCandidateUrlFromReferenceAutolink(href) : href; const pullRequestCandidate = pullRequestCandidateUrl ? parseChangeRequestUrl(pullRequestCandidateUrl) : null; const pullRequestProject = environmentId !== null && serverConfig?.environment.capabilities.pullRequests === true && pullRequestCandidate !== null ? findProjectForChangeRequest( projects.filter((project) => project.environmentId === environmentId), pullRequestCandidate, ) : undefined; const pullRequestPreviewTarget = environmentId === null || pullRequestProject === undefined || pullRequestCandidate === null ? null : { environmentId, input: { projectId: pullRequestProject.id, repository: pullRequestProject.repositoryIdentity?.displayName ?? pullRequestCandidate.repository, number: pullRequestCandidate.number, }, }; const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); const linkChildren = {children}; const link = ( { onClick?.(event); if (isSameDocumentLink && href) { handleMarkdownFragmentClick(event, href); return; } if ( href && faviconHost !== null && mediaKindFromPath(href) !== null && !event.defaultPrevented && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey ) { event.preventDefault(); event.stopPropagation(); openMarkdownMedia( href, undefined, event.target instanceof HTMLImageElement ? event.target : event.currentTarget.querySelector("img"), ); 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. if ( !href || openChangeRequestLink(event, href, undefined, environmentId ?? undefined) ) { return; } // Anything else follows the "Open links in" setting. The system browser // keeps the `_blank` the shell already handles; the in-app browser needs // the click intercepted here. A modifier click is the way out of the // in-app default, so it is left to the shell too. if ( event.defaultPrevented || resolveLinkTarget({ url: href, event, preference: linkTargetPreference, canOpenInApp: canOpenInPreview, }) !== "app" ) { return; } event.preventDefault(); event.stopPropagation(); // Keep the link here if saved settings could not be read. void openExternalLinkInPreview(href).then((result) => { if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; reportMarkdownActionFailure( { operation: "open-link-in-preview", target: href }, result.cause, ); if (squashAtomCommandFailure(result) instanceof BrowserSettingsReadError) return; void readLocalApi()?.shell.openExternal(href); }); }} onContextMenu={(event) => { if (!href || !faviconHost) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; const threadLinkAction = linkedThreadPullRequestFor(href) !== null ? "unlink-from-thread" : resolveThreadPullRequest(href) === null ? undefined : "link-to-thread"; void showExternalLinkContextMenu({ href, canOpenInPreview, threadLinkAction, 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"), updateThreadLink: updateThreadPullRequestLink, reportFailure: (operation, cause) => { reportMarkdownActionFailure({ operation, target: href }, cause); if ( operation === "link-pull-request-to-thread" || operation === "unlink-pull-request-from-thread" ) { toastManager.add( stackedThreadToast({ type: "error", title: operation === "link-pull-request-to-thread" ? "Unable to link pull request" : "Unable to unlink pull request", description: cause instanceof Error ? cause.message : "The request failed.", }), ); } }, }); }} > {faviconHost && hastHasText(node) && !isPullRequestAutolink ? ( {linkChildren} ) : ( linkChildren )} ); if (!faviconHost || !href) { return link; } if (pullRequestPreviewTarget !== null) { return ( openChangeRequestLink( { metaKey: false, ctrlKey: false, preventDefault: () => undefined, stopPropagation: () => undefined, }, targetUrl, undefined, environmentId ?? undefined, ) } onOpenFallback={openDeferredMarkdownLink} /> ); } return ( {href} ); } return fileLinkChip( fileLinkMeta, `[${fileLinkMeta.basename}](${normalizedHref})`, props.className, normalizedHref, ); }, code: function MarkdownCode({ node, children, className, ...props }) { const { cwd, imageBaseDir, inlineCodeFileLinkMetaByText, fileLinkChip } = use( ChatMarkdownRendererContext, ); if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); const fileLinkMeta = inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? resolveInlineCodeFileLinkMeta(codeText, cwd, imageBaseDir ?? cwd); if (fileLinkMeta) { return fileLinkChip( fileLinkMeta, `\`${codeText}\``, undefined, inlineCodeFilePathCandidate(codeText) ?? codeText.trim(), ); } } return ( {children} ); }, img: function MarkdownImage({ node, title, src, alt, ...props }) { const { expandMedia, cwd, imageBaseDir, threadRef } = use(ChatMarkdownRendererContext); const imageExpand = use(MarkdownLinkContext) ? undefined : expandMedia; const localSrc = node?.properties?.dataLocalSrc; const markdownTitle = node?.properties?.dataMarkdownTitle; const standalone = node?.properties?.dataStandalone === true; const authoredSrc = typeof localSrc === "string" ? localSrc : src; const authoredTitle = typeof markdownTitle === "string" ? markdownTitle : title; const srcString = typeof authoredSrc === "string" ? normalizeMarkdownLinkDestination(authoredSrc) : ""; const classifiedSrc = typeof localSrc === "string" ? srcString.replaceAll("\\", "/") : srcString; const altText = alt ?? ""; const copyMarkdown = markdownImageCopy(altText, srcString, authoredTitle); const { className, style: _style, width, height, ...imageProps } = props; const authoredSizeStyle = authoredImageSizeStyle(width, height); const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); const kind = mediaKindFromPath(classifiedSrc) ?? "image"; if (imageSource._tag === "Direct") { const mediaSrc = resolveProtocolRelativeMediaUrl(imageSource.uri); const originalUrl = resolveExternalWebLinkHost(imageSource.uri) !== null ? imageSource.uri : undefined; const reference = mediaUrlReference(imageSource.uri); const actionsSource: MediaActionSource = { kind, name: altText || kind, src: mediaSrc, ...(reference ? { reference } : {}), }; if (kind === "video") { return ( ); } return ( ); } if (imageSource._tag === "WorkspaceFile" && threadRef) { return ( ); } return ; }, table: function MarkdownTableRenderer({ node: _node, ...props }) { return ; }, details: function MarkdownDetailsRenderer({ node: _node, children, open: detailsOpen }) { return {children}; }, pre: function MarkdownPre({ node, children, ...props }) { const { resolvedTheme, diffThemeName, isStreaming } = use(ChatMarkdownRendererContext); const codeBlock = extractCodeBlock(children); if (!codeBlock) { return
    {children}
    ; } const language = extractFenceLanguage(codeBlock.className); const fenceTitle = extractFenceTitle(extractPreCodeMeta(node)); return ( {children}} > {children}}> ); }, } satisfies Components; function ChatMarkdown({ text, className, lineBreaks = false, parseRawHtml = true, extraRemarkPlugins = EMPTY_REMARK_PLUGINS, ...props }: ChatMarkdownProps) { const { componentState, handleCopy, markdownRef, markdownUrlTransform, localMediaPreview, setLocalMediaPreview, } = useChatMarkdownState({ text, ...props }); const remarkPlugins = useMemo( () => [ ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), ...extraRemarkPlugins, ], [extraRemarkPlugins, lineBreaks], ); // 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} {localMediaPreview ? ( setLocalMediaPreview(null)} /> ) : null}
    ); } export default memo(ChatMarkdown);