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 { EnvironmentId, ScopedThreadRef, ServerProviderSkill, ThreadLinkedPullRequest, } from "@t3tools/contracts"; 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 * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { Children, Suspense, type CSSProperties, 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 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 { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; 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 { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, serializeTableElementToMarkdown, } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { extractMarkdownLinkHrefs, isWindowsDrivePathHref, normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, shouldOpenMarkdownFileLinkInBrowserByDefault, shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { 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 { threadEnvironment } from "../state/threads"; import { claimWorkspaceBasenameLookup, needsWorkspaceBasenameLookup, pickWorkspaceBasenameMatch, WORKSPACE_BASENAME_LOOKUP_LIMIT, } from "../workspaceBasenameLookup"; import { findProjectForChangeRequest, matchesLinkedPullRequestUrl, parseChangeRequestUrl, useOpenChangeRequestLink, } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; import { resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: 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; imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; } 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; }): boolean { return input.canOpenInEditor || input.canOpenInBrowser || input.canOpenInPanel; } 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 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 markers up to * two characters wide. Once a marker reaches three characters (item 100+), * `list-style-position: outside` paints it wider than that gutter and clips * the leading character against the item's own overflow. Rather than widening * the gutter for every list, only lists whose widest marker is 3+ characters * get a wider `--list-gutter`. The width includes a negative marker's minus * sign. */ export 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 <= 2) return undefined; return { "--list-gutter": `${markerWidth + 1}ch` }; } type MarkdownImageHastNode = { type?: string; tagName?: string; properties?: Record; children?: MarkdownImageHastNode[]; }; /** 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); }; } 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], img: [...(defaultSchema.attributes?.img ?? []), "dataLocalSrc", "dataMarkdownTitle"], }, protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file"], 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; workspaceRelativePath: string | null; line?: number | undefined; label: string; copyMarkdown: string; theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen?: ((targetPath: string) => Promise>) | undefined; onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | 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(); const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); return ( {failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( { failedFaviconHosts.add(host); setFailedHost(host); }} /> )} ); }); const CHAT_MARKDOWN_IMAGE_BOUNDS_CLASS_NAME = "max-h-[30rem] max-w-[min(100%,30rem)]"; const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = cn( "h-auto w-auto object-contain", CHAT_MARKDOWN_IMAGE_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})`; } function authoredImageSizeStyle( width: string | number | undefined, height: string | number | undefined, ): 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, ${(30 * 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_LAYOUT_CLASS_NAME = "inline-block!"; const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, CHAT_MARKDOWN_WORKSPACE_IMAGE_LAYOUT_CLASS_NAME, "rounded-lg border border-border/40", ); const MarkdownLinkContext = React.createContext(false); function expandableMarkdownImageProps( onImageExpand: ((preview: ExpandedImagePreview) => void) | undefined, src: string, 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(); onImageExpand({ images: [{ src, name: previewName }], index: 0 }); }; 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 ChatMarkdownImageFallback(props: { readonly alt: string; readonly copyMarkdown?: string | undefined; }) { return ( {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} ); } /** Markdown images whose src is a workspace file path load through a signed asset URL. */ const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(props: { readonly threadRef: ScopedThreadRef; readonly path: string; readonly alt: string; readonly copyMarkdown: string; readonly srcFragment: string; readonly style?: CSSProperties | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; }) { const assetUrl = useAssetUrlState(props.threadRef.environmentId, { _tag: "workspace-file", threadId: props.threadRef.threadId, path: props.path, }); const [failedUrl, setFailedUrl] = useState(null); if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { return ; } if (assetUrl._tag !== "Success") { return ( ); } const src = assetUrl.url + props.srcFragment; return ( {props.alt} setFailedUrl(assetUrl.url)} /> ); }); function leadingExternalLinkTextLength(text: string): number { const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0]; if (protocol) return protocol.length; return Math.min(text.length, 1); } function breakableExternalLinkText(text: string): ReactNode[] { return Array.from(text, (character, index) => ( {character} )); } function plainHastText(node: unknown): string | null { if (!node || typeof node !== "object" || !("children" in node) || !Array.isArray(node.children)) { return null; } const parts = node.children.map((child) => { if ( child && typeof child === "object" && "type" in child && child.type === "text" && "value" in child && typeof child.value === "string" ) { return child.value; } return null; }); return parts.every((part) => part !== null) ? parts.join("") : null; } /** * Whether the link carries any words of its own. An anchor that is only an image — a badge, a * "Fix in Cursor" button — already shows its identity, and a favicon bolted on in front of it * is a stray logo rather than a hint. */ function hastHasText(node: unknown): boolean { if (!node || typeof node !== "object") return false; if ( "type" in node && node.type === "text" && "value" in node && typeof node.value === "string" && node.value.trim().length > 0 ) { return true; } return "children" in node && Array.isArray(node.children) && node.children.some(hastHasText); } const SANITIZED_FRAGMENT_PREFIX = "user-content-"; function decodeMarkdownFragmentId(href: string): string { const encodedId = href.slice(1); try { return decodeURIComponent(encodedId); } catch { return encodedId; } } function normalizeSanitizedFragmentId(id: string): string { let normalizedId = id; while (normalizedId.startsWith(SANITIZED_FRAGMENT_PREFIX)) { normalizedId = normalizedId.slice(SANITIZED_FRAGMENT_PREFIX.length); } return normalizedId; } function findMarkdownFragmentTarget(anchor: HTMLAnchorElement, href: string): HTMLElement | null { const decodedId = decodeMarkdownFragmentId(href); const normalizedId = normalizeSanitizedFragmentId(decodedId); const matchesFragment = (element: HTMLElement) => element.id === decodedId || normalizeSanitizedFragmentId(element.id) === normalizedId; const markdownRoot = anchor.closest(".chat-markdown"); if (markdownRoot) { const localTargets = Array.from(markdownRoot.querySelectorAll("[id]")); const localTarget = localTargets.find(matchesFragment); if (localTarget) return localTarget; } return ( document.getElementById(decodedId) ?? Array.from(document.querySelectorAll("[id]")).find(matchesFragment) ?? null ); } function handleMarkdownFragmentClick(event: ReactMouseEvent, href: string) { if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) { return; } const target = findMarkdownFragmentTarget(event.currentTarget, href); if (!target) return; event.preventDefault(); const nextUrl = new URL(window.location.href); nextUrl.hash = href.slice(1); window.history.pushState(window.history.state, "", nextUrl); target.scrollIntoView({ block: "nearest" }); } function MarkdownExternalLinkContent({ host, plainText, children, }: { host: string; plainText: string | null; children: ReactNode; }) { if (plainText) { const leadingLength = leadingExternalLinkTextLength(plainText); return ( <> {plainText.slice(0, leadingLength)} {breakableExternalLinkText(plainText.slice(leadingLength))} ); } const childNodes = Children.toArray(children); const firstChild = childNodes[0]; if (typeof firstChild === "string" && firstChild.length > 0) { const leadingLength = leadingExternalLinkTextLength(firstChild); return ( <> {firstChild.slice(0, leadingLength)} {breakableExternalLinkText(firstChild.slice(leadingLength))} {childNodes.slice(1)} ); } return ( <> {firstChild} {childNodes.slice(1)} ); } const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, iconPath, displayPath, workspaceRelativePath, line, label, copyMarkdown, theme, threadRef, onOpen, onOpenInPanel, openInEditorMenuLabel, onOpenInBrowser, 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 || !workspaceRelativePath) { handleOpenInEditor(); return; } onOpenInPanel(workspaceRelativePath, line); }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { return; } void (async () => { try { const result = await onOpenInBrowser(); if (result._tag === "Success" || isAtomCommandInterrupted(result)) { return; } reportMarkdownActionFailure( { operation: "open-file-in-browser", target: targetPath }, result.cause, ); const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open file in browser", description: error instanceof Error ? error.message : "An error occurred.", }), ); } catch (cause) { reportMarkdownActionFailure( { operation: "open-file-in-browser", target: targetPath }, cause, ); toastManager.add( stackedThreadToast({ type: "error", title: "Unable to open file in browser", description: cause instanceof Error ? cause.message : "An error occurred.", }), ); } })(); }, [onOpenInBrowser, targetPath]); const 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( [ ...(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 === "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, 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(workspaceRelativePath); const hasPrimaryAction = hasMarkdownFilePrimaryAction({ canOpenInEditor, canOpenInBrowser, canOpenInPanel, }); 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.workspaceRelativePath === next.workspaceRelativePath && previous.line === next.line && previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && previous.theme === next.theme && previous.threadRef === next.threadRef && previous.onOpen === next.onOpen && previous.onOpenInPanel === next.onOpenInPanel && previous.openInEditorMenuLabel === next.openInEditorMenuLabel && previous.onOpenInBrowser === next.onOpenInBrowser && previous.onReveal === next.onReveal && previous.revealLabel === next.revealLabel && previous.className === next.className ); } function ChatMarkdown({ text, cwd, threadRef, environmentId: explicitEnvironmentId, onTaskListChange, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, parseRawHtml = true, onUseArtifactTemplate, imageBaseDir, onImageExpand, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { reportFailure: false, }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const environmentId = threadRef?.environmentId ?? explicitEnvironmentId ?? null; const remoteOpen = useRemoteOpenResolution(environmentId); const canUseShellActions = canUseMarkdownFileShellActions( environmentId, remoteOpen.state.mode, remoteOpen.isResolved, ); const preparedConnection = usePreparedConnection(environmentId); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const threadServerConfig = useAtomValue( serverEnvironment.configValueAtom(threadRef?.environmentId ?? 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); if (meta) { metaByHref.set(normalizedHref, meta); } } return metaByHref; }, [cwd, text]); const inlineCodeFileLinkMetaByText = useMemo(() => { const metaByText = new Map(); for (const span of extractInlineCodeSpans(text)) { if (metaByText.has(span)) continue; const meta = resolveInlineCodeFileLinkMeta(span, cwd); if (meta) { metaByText.set(span, meta); } } return metaByText; }, [cwd, text]); const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [ ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), ...[...inlineCodeFileLinkMetaByText.values()].map((meta) => meta.filePath), ]; return buildFileLinkParentSuffixByPath(filePaths); }, [inlineCodeFileLinkMetaByText, markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { 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); const resolveThreadPullRequest = useCallback( (href: string): ThreadLinkedPullRequest | null => { if ( threadRef === undefined || readThreadShell(threadRef) === null || threadServerConfig?.environment.capabilities.threadPullRequestLinking !== true ) { return null; } const parsed = parseChangeRequestUrl(href); if (parsed === null) return null; const project = findProjectForChangeRequest( projects.filter((candidate) => candidate.environmentId === threadRef.environmentId), parsed, ); if (project === undefined) return null; return { projectId: project.id, repository: project.repositoryIdentity?.displayName ?? parsed.repository, number: parsed.number, url: href, }; }, [projects, threadRef, threadServerConfig], ); const updateThreadPullRequestLink = useCallback( async (href: string, linked: boolean) => { if (threadRef === undefined) return; const linkedPullRequest = linked ? resolveThreadPullRequest(href) : null; if (linked && linkedPullRequest === null) { throw new Error("The pull request is not available in this environment."); } if (!linked) { const currentPullRequest = readThreadShell(threadRef)?.linkedPullRequest; if (currentPullRequest == null || !matchesLinkedPullRequestUrl(currentPullRequest, href)) { return; } } const result = await updateThreadMetadata({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId, linkedPullRequest }, }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { throw squashAtomCommandFailure(result); } }, [resolveThreadPullRequest, threadRef, updateThreadMetadata], ); const openExternalLinkInPreview = useCallback( (url: string) => { if (!threadRef) { return Promise.resolve( AsyncResult.failure( Cause.fail( new BrowserPreviewUnavailableError({ message: "Thread context is unavailable.", }), ), ), ); } return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { if (result._tag === "Success") recordVisitForThread(threadRef, url); return result; }); }, [openPreview, threadRef], ); const openMarkdownFileInPreview = useCallback( (path: string) => { if (!threadRef || preparedConnection._tag === "None") { return Promise.resolve( AsyncResult.failure( Cause.fail( new BrowserPreviewUnavailableError({ message: "Environment is not connected.", }), ), ), ); } return openFileInPreview({ threadRef, filePath: path, httpBaseUrl: preparedConnection.value.httpBaseUrl, createAssetUrl, openPreview, }); }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); 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. const openFileInPanel = useCallback( (workspaceRelativePath: string, line: number | undefined) => { if (!threadRef) return; // Claimed on every open so a synchronous one supersedes a lookup already // in flight. const isLatestLookup = claimWorkspaceBasenameLookup(); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line); if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { openAt(workspaceRelativePath); return; } void (async () => { const match = await findWorkspaceBasenameMatch(workspaceRelativePath); if (!isLatestLookup()) return; openAt(match ?? workspaceRelativePath); })(); }, [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], ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that * metadata changes. */ const markdownComponents = useMemo(() => { const fileLinkChip = ( fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, className?: string, ) => { const parentSuffix = fileLinkParentSuffixByPath.get( fileLinkMeta.filePath.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}` : ""}`, ); } return ( revealMarkdownFileInFileManager(fileLinkMeta) : undefined } revealLabel={revealInFileManagerLabel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && isBrowserPreviewFile(fileLinkMeta.filePath) ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) : undefined } className={className} /> ); }; return { div({ node, children, ...props }) { const artifactTemplate = artifactTemplateFromHastProperties(node?.properties); if (artifactTemplate) { return ( ); } return
{children}
; }, p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

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

{alert.label}

{children}
); }, ol({ node, start, style, ...props }) { const itemCount = node?.children?.filter((child) => child.type === "element" && child.tagName === "li") .length ?? 0; const gutterStyle = orderedListGutterStyle(itemCount, start); return (
    ); }, li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; return (
  1. {renderSkillInlineMarkdownChildren(children, skills)}
  2. ); }, input({ node: _node, type, checked, disabled: _disabled, ...props }) { if (type !== "checkbox" || !onTaskListChange) { return ( ); } return ( { const markerOffset = Number( event.currentTarget.closest("li")?.dataset.taskMarkerOffset, ); if (!Number.isSafeInteger(markerOffset)) return; onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); }} /> ); }, a({ node, href, children, title: _title, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? resolveMarkdownFileLinkMeta(normalizedHref, cwd)) : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); 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; } // A link to a change request in a workspace project opens beside the // conversation instead of in a browser: it is the thing being talked about, and // the panel it opens offers the browser as one of its actions. Anything else is // an ordinary link and keeps the `_blank` the shell already handles. if (href) openChangeRequestLink(event, href); }} onContextMenu={(event) => { if (!href || !faviconHost) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; const pullRequest = resolveThreadPullRequest(href); const currentPullRequest = threadRef === undefined ? null : readThreadShell(threadRef)?.linkedPullRequest; const threadLinkAction = currentPullRequest != null && matchesLinkedPullRequestUrl(currentPullRequest, href) ? "unlink-from-thread" : pullRequest === 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) ? ( {linkChildren} ) : ( linkChildren )} ); if (!faviconHost || !href) { return link; } return ( {href} ); } return fileLinkChip( fileLinkMeta, `[${fileLinkMeta.basename}](${normalizedHref})`, props.className, ); }, code({ node, children, className, ...props }) { if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); const fileLinkMeta = inlineCodeFileLinkMetaByText.get(codeText.trim()) ?? resolveInlineCodeFileLinkMeta(codeText, cwd); if (fileLinkMeta) { return fileLinkChip(fileLinkMeta, `\`${codeText}\``); } } return ( {children} ); }, img: function MarkdownImage({ node, title, src, alt, ...props }) { const imageExpand = use(MarkdownLinkContext) ? undefined : onImageExpand; const localSrc = node?.properties?.dataLocalSrc; const markdownTitle = node?.properties?.dataMarkdownTitle; 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 authoredSizeStyle = authoredImageSizeStyle(props.width, props.height); const imageSource = classifyMarkdownImageSource(classifiedSrc, imageBaseDir ?? cwd); if (imageSource._tag === "Direct") { return ( {altText} ); } if (imageSource._tag === "WorkspaceFile" && threadRef) { return ( ); } return ; }, 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}}> ); }, }; }, [ canUseShellActions, cwd, diffThemeName, fileLinkParentSuffixByPath, inlineCodeFileLinkMetaByText, imageBaseDir, isStreaming, markdownFileLinkMetaByHref, onTaskListChange, onUseArtifactTemplate, onImageExpand, openFileInPanel, openInPreferredEditor, openChangeRequestLink, openExternalLinkInPreview, openMarkdownFileInPreview, preferredEditorMenuLabel, resolveThreadPullRequest, resolvedTheme, revealMarkdownFileInFileManager, revealInFileManagerLabel, skills, text, threadRef, updateThreadPullRequestLink, ]); /* eslint-enable react/no-unstable-nested-components */ // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the // complete source token instead of dropping it from the rendered message. return (
    {text}
    ); } export default memo(ChatMarkdown);