import React from 'react'; import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'; import { useAppTranslation } from '@features/localization/renderer'; import { api } from '@renderer/api'; import { CopyButton } from '@renderer/components/common/CopyButton'; import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard'; import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; import { CODE_BG, CODE_BORDER, CODE_HEADER_BG, COLOR_TEXT, COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY, PROSE_BLOCKQUOTE_BORDER, PROSE_BODY, PROSE_CODE_BG, PROSE_CODE_TEXT, PROSE_HEADING, PROSE_LINK, PROSE_MUTED, PROSE_PRE_BG, PROSE_PRE_BORDER, PROSE_TABLE_BORDER, PROSE_TABLE_HEADER_BG, } from '@renderer/constants/cssVariables'; import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { resolveFilePath } from '@renderer/store/utils/pathResolution'; import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; import { nameColorSet } from '@renderer/utils/projectColor'; import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { FileText, UsersRound } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; import { extractTextFromReactNode } from '../markdownCopyUtils'; import { createSearchContext, EMPTY_SEARCH_MATCHES, highlightSearchInChildren, type SearchContext, } from '../searchHighlightUtils'; import { highlightLine } from '../viewers/syntaxHighlighter'; import { FileLink, isRelativeUrl } from './FileLink'; import { MermaidDiagram } from './MermaidDiagram'; // ============================================================================= // Types // ============================================================================= interface MarkdownViewerProps { content: string; maxHeight?: string; // e.g., "max-h-64" or "max-h-96" className?: string; label?: string; // Optional label like "Thinking", "Output", etc. /** When provided, enables search term highlighting within the markdown */ itemId?: string; /** Optional override for search highlighting (local search, e.g. Claude logs) */ searchQueryOverride?: string; /** When true, shows a copy button (overlay when no label, inline in header when label exists) */ copyable?: boolean; /** When true, renders without wrapper background/border (for embedding inside cards) */ bare?: boolean; /** Base directory for resolving relative URLs (images, links) via local-resource:// protocol */ baseDir?: string; /** Optional precomputed team color map to avoid subscribing to the full team list. */ teamColorByName?: ReadonlyMap; /** Optional team click handler to avoid subscribing to store in leaf renderers. */ onTeamClick?: (teamName: string) => void; } interface CompactMarkdownPreviewProps { content: string; className?: string; /** Optional precomputed team color map to avoid subscribing to the full team list. */ teamColorByName?: ReadonlyMap; /** Optional team click handler to avoid subscribing to store in leaf renderers. */ onTeamClick?: (teamName: string) => void; } const EMPTY_TEAMS: { teamName?: string; displayName?: string; color?: string }[] = []; const EMPTY_TEAM_COLOR_MAP = new Map(); const NOOP_TEAM_CLICK = (): void => undefined; type ViewerMarkdownMode = 'default' | 'compact-preview'; interface HastElementLike { tagName?: string; value?: string; children?: unknown[]; } // ============================================================================= // Helpers // ============================================================================= function isHastElementLike(value: unknown): value is HastElementLike { return typeof value === 'object' && value !== null; } function getHastChildren(value: unknown): unknown[] { return isHastElementLike(value) && Array.isArray(value.children) ? value.children : []; } function getHastText(value: unknown): string { if (typeof value === 'string' || typeof value === 'number') { return String(value); } if (!isHastElementLike(value)) { return ''; } if (typeof value.value === 'string') { return value.value; } return getHastChildren(value).map(getHastText).join(' ').replace(/\s+/g, ' ').trim(); } function collectHastElementsByTag(value: unknown, tagName: string): HastElementLike[] { const result: HastElementLike[] = []; const visit = (node: unknown): void => { if (!isHastElementLike(node)) return; if (node.tagName === tagName) { result.push(node); } for (const child of getHastChildren(node)) { visit(child); } }; visit(value); return result; } function getDirectCellElements(row: HastElementLike): HastElementLike[] { return getHastChildren(row).filter( (child): child is HastElementLike => isHastElementLike(child) && (child.tagName === 'th' || child.tagName === 'td') ); } function buildCompactTableSummary(node: unknown, fallbackChildren: React.ReactNode): string { const rows = collectHastElementsByTag(node, 'tr'); const headerRow = rows.find((row) => getDirectCellElements(row).some((cell) => cell.tagName === 'th')) ?? rows[0] ?? null; const headerCells = headerRow ? getDirectCellElements(headerRow) : []; const headers = headerCells.map(getHastText).filter(Boolean); const bodyRowCount = rows.filter((row) => row !== headerRow).length; const fallbackText = extractTextFromReactNode(fallbackChildren).replace(/\s+/g, ' ').trim(); const previewSource = headers.length > 0 ? headers.join(' | ') : fallbackText; const preview = previewSource.length > 72 ? `${previewSource.slice(0, 69).trimEnd()}...` : previewSource; const rowLabel = bodyRowCount === 1 ? '1 row' : `${bodyRowCount} rows`; if (preview) { return `Table: ${preview} - ${rowLabel}`; } return `Table - ${rowLabel}`; } /** * Custom URL transform that preserves task://, mention://, and team:// protocols. * react-markdown v10 strips non-standard protocols by default. */ function allowCustomProtocols(url: string): string { if (url.startsWith('task://') || url.startsWith('mention://') || url.startsWith('team://')) return url; return defaultUrlTransform(url); } /** * Set of standard HTML element tag names. * Used to filter out non-HTML XML-like tags (e.g. ``, ``) * that appear in agent messages and cause React "unrecognized tag" warnings. */ const STANDARD_HTML_TAGS = new Set([ 'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio', 'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'cite', 'code', 'col', 'colgroup', 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt', 'em', 'embed', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'link', 'main', 'map', 'mark', 'menu', 'meta', 'meter', 'nav', 'noscript', 'object', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'search', 'section', 'select', 'slot', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track', 'u', 'ul', 'var', 'video', 'wbr', // SVG elements commonly used inline 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use', 'text', 'tspan', 'clippath', 'mask', 'pattern', 'image', 'foreignobject', ]); /** * Filter for react-markdown's `allowElement` prop. * Returns false for non-standard HTML tags (e.g. ``, ``), * which causes react-markdown to render their text content instead of the element. * This prevents React "unrecognized tag" warnings from XML-like tags in agent messages. */ function isAllowedElement(element: { tagName: string }): boolean { return STANDARD_HTML_TAGS.has(element.tagName.toLowerCase()); } /** Resolve a relative path to an absolute path given a base directory */ export function resolveRelativePath(relativeSrc: string, baseDir: string): string { return resolveFilePath(baseDir, relativeSrc); } // ============================================================================= // LocalImage — loads images via IPC (readBinaryPreview) for local file access // ============================================================================= interface LocalImageProps { src: string; alt?: string; baseDir: string; } const LocalImage = React.memo(function LocalImage({ src, alt, baseDir, }: LocalImageProps): React.ReactElement { const { t } = useAppTranslation('common'); const [dataUrl, setDataUrl] = React.useState(null); const [error, setError] = React.useState(false); React.useEffect(() => { let cancelled = false; setDataUrl(null); setError(false); const fullPath = resolveRelativePath(src, baseDir); window.electronAPI.editor .readBinaryPreview(fullPath) .then((result) => { if (!cancelled) { setDataUrl(`data:${result.mimeType};base64,${result.base64}`); } }) .catch(() => { if (!cancelled) setError(true); }); return () => { cancelled = true; }; }, [src, baseDir]); if (error) { return ( {t('markdown.imageFallback', { label: alt || src })} ); } if (!dataUrl) { return ( ); } return {alt; }); /** Extract plain text from a hast (HTML AST) node tree */ interface HastNode { type: string; value?: string; children?: HastNode[]; } function hastToText(node: HastNode): string { if (node.type === 'text') return node.value ?? ''; if (node.children) return node.children.map(hastToText).join(''); return ''; } function extractLanguageFromClassName(className?: string): string { return /(?:^|\s)language-([^\s]+)/.exec(className ?? '')?.[1] ?? ''; } // ============================================================================= // Component factories // ============================================================================= function createViewerMarkdownComponents( searchCtx: SearchContext | null, isLight = false, teamColorByName: ReadonlyMap = new Map(), onTeamClick?: (teamName: string) => void, copyCodeBlocks: boolean = false, mode: ViewerMarkdownMode = 'default' ): Components { const hl = (children: React.ReactNode): React.ReactNode => searchCtx ? highlightSearchInChildren(children, searchCtx) : children; const isCompactPreview = mode === 'compact-preview'; const renderCompactInline = ( children: React.ReactNode, className: string, style: React.CSSProperties ): React.ReactElement => ( {hl(children)}{' '} ); const renderCompactTableSummary = ( node: unknown, children: React.ReactNode ): React.ReactElement => { const summary = buildCompactTableSummary(node, children); return ( {summary}{' '} ); }; return { // Headings h1: ({ children }) => isCompactPreview ? ( renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) ) : (

{hl(children)}

), h2: ({ children }) => isCompactPreview ? ( renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) ) : (

{hl(children)}

), h3: ({ children }) => isCompactPreview ? ( renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) ) : (

{hl(children)}

), h4: ({ children }) => isCompactPreview ? ( renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING }) ) : (

{hl(children)}

), h5: ({ children }) => isCompactPreview ? ( renderCompactInline(children, 'font-medium', { color: PROSE_HEADING }) ) : (
{hl(children)}
), h6: ({ children }) => isCompactPreview ? ( renderCompactInline(children, 'font-medium', { color: PROSE_HEADING }) ) : (
{hl(children)}
), // Paragraphs p: ({ children }) => isCompactPreview ? ( renderCompactInline(children, '', { color: PROSE_BODY }) ) : (

{hl(children)}

), // Links — inline element, no hl(); parent block element's hl() descends here // task:// links render with TaskTooltip + are clickable via ancestor onClickCapture // mention:// links render as colored inline badges a: ({ href, children }) => { if (href?.startsWith('mention://')) { const path = href.slice('mention://'.length); const slashIdx = path.indexOf('/'); let color = ''; let memberName = ''; try { color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : ''; memberName = slashIdx >= 0 ? decodeURIComponent(path.slice(slashIdx + 1)) : ''; } catch { // malformed percent-encoding — use empty color/name } const colorSet = getTeamColorSet(color); const bg = getThemedBadge(colorSet, isLight); const badge = ( {children} ); if (memberName) { return ( {badge} ); } return badge; } if (href?.startsWith('team://')) { let teamLabel = ''; try { teamLabel = decodeURIComponent(href.slice('team://'.length)); } catch { // malformed percent-encoding — fall back to deterministic name color } const teamColor = teamColorByName.get(teamLabel); const colorSet = teamColor ? getTeamColorSet(teamColor) : nameColorSet(teamLabel, isLight); const bg = getThemedBadge(colorSet, isLight); const badgeStyle: React.CSSProperties = { backgroundColor: bg, color: colorSet.text, borderRadius: '3px', boxShadow: `0 0 0 1.5px ${bg}`, fontSize: 'inherit', cursor: onTeamClick ? 'pointer' : 'default', display: 'inline-flex', alignItems: 'center', gap: '2px', border: 'none', padding: 0, font: 'inherit', lineHeight: 'inherit', }; if (onTeamClick && teamLabel) { return ( ); } return ( {children} ); } if (href?.startsWith('task://')) { const parsedTaskLink = parseTaskLinkHref(href); const taskId = parsedTaskLink?.taskId; if (!taskId) { return <>{children}; } return ( e.preventDefault()} > {children} ); } // Relative file paths — open in built-in editor or copy path if (href && isRelativeUrl(href)) { return {children}; } return ( { e.preventDefault(); if (href) { void api.openExternal(href); } }} > {children} ); }, // Strong/Bold — inline element, no hl() strong: ({ children }) => ( {children} ), // Emphasis/Italic — inline element, no hl() em: ({ children }) => ( {children} ), // Strikethrough — inline element, no hl() del: ({ children }) => ( {children} ), // Code: inline vs block detection (block code is highlighted by rehype-highlight; preserve hljs class) code: (props) => { const { className: codeClassName, children, node, } = props as { className?: string; children?: React.ReactNode; node?: { position?: { start: { line: number }; end: { line: number } } }; }; const hasLanguage = codeClassName?.includes('language-'); const isMultiLine = (node?.position && node.position.end.line > node.position.start.line) ?? false; const isBlock = (hasLanguage ?? false) || isMultiLine; if (isBlock) { const lang = extractLanguageFromClassName(codeClassName); const raw = typeof children === 'string' ? children : extractTextFromReactNode(children); const text = raw.replace(/\n$/, ''); const lines = text.split('\n'); return ( {lines.map((line, i) => ( {hl(highlightLine(line, lang))} {i < lines.length - 1 ? '\n' : null} ))} ); } // Inline code — no hl(); parent block element's hl() descends here return ( {children} ); }, // Code blocks — intercept mermaid diagrams at the pre level pre: ({ children, node }) => { if (isCompactPreview) { const compactText = extractTextFromReactNode(children).trim(); return ( {compactText} ); } // Check if this pre contains a mermaid code block const codeEl = node?.children?.[0]; if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) { const cls = (codeEl.properties as Record)?.className; if (Array.isArray(cls) && cls.some((c) => String(c) === 'language-mermaid')) { return ; } } const codeText = copyCodeBlocks ? extractTextFromReactNode(children).trim() : ''; return (
          {codeText ?  : null}
          {children}
        
); }, // Blockquotes blockquote: ({ children }) => isCompactPreview ? ( renderCompactInline(children, 'italic', { color: PROSE_MUTED }) ) : (
{hl(children)}
), // Lists ul: ({ children }) => isCompactPreview ? ( {children} ) : (
    {children}
), ol: ({ children }) => isCompactPreview ? ( {children} ) : (
    {children}
), li: ({ children }) => isCompactPreview ? ( • {hl(children)}{' '} ) : (
  • {hl(children)}
  • ), // Tables table: ({ children, node }) => isCompactPreview ? ( renderCompactTableSummary(node, children) ) : (
    {children}
    ), thead: ({ children }) => isCompactPreview ? ( {children} ) : ( {children} ), th: ({ children }) => isCompactPreview ? ( {hl(children)} ) : ( {hl(children)} ), td: ({ children }) => isCompactPreview ? ( {hl(children)} ) : ( {hl(children)} ), // Horizontal rule hr: () => isCompactPreview ? ( · ) : (
    ), }; } // Markdown + syntax highlighting can freeze the renderer on some inputs // (very large text, huge code blocks, pathological markdown). Keep the UI responsive: // - for medium/large content: disable syntax highlighting // - for very large content: show a raw preview instead of parsing markdown const DISABLE_HIGHLIGHT_CHARS = 12_000; const MAX_MARKDOWN_CHARS = 60_000; const LARGE_PREVIEW_CHARS = 30_000; // ============================================================================= // Component // ============================================================================= function useResolvedViewerTeamContext( providedTeamColorByName?: ReadonlyMap, providedOnTeamClick?: (teamName: string) => void ): { teamColorByName: ReadonlyMap; onTeamClick?: (teamName: string) => void; } { const teams = useStore(useShallow((s) => (providedTeamColorByName ? EMPTY_TEAMS : s.teams))); const openTeamTab = useStore((s) => (providedOnTeamClick ? NOOP_TEAM_CLICK : s.openTeamTab)); const fallbackTeamColorByName = React.useMemo(() => { const result = new Map(); for (const team of teams) { if (team.teamName) { result.set(team.teamName, team.color ?? ''); } if (team.displayName) { result.set(team.displayName, team.color ?? ''); } } return result; }, [teams]); return { teamColorByName: providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP, onTeamClick: providedOnTeamClick ?? openTeamTab, }; } export const CompactMarkdownPreview: React.FC = React.memo( function CompactMarkdownPreview({ content, className = '', teamColorByName: providedTeamColorByName, onTeamClick: providedOnTeamClick, }) { const { isLight } = useTheme(); const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( providedTeamColorByName, providedOnTeamClick ); const components = React.useMemo( () => createViewerMarkdownComponents( null, isLight, teamColorByName, onTeamClick, false, 'compact-preview' ), [isLight, onTeamClick, teamColorByName] ); return (
    {content}
    ); } ); export const MarkdownViewer: React.FC = React.memo(function MarkdownViewer({ content, maxHeight = 'max-h-96', className = '', label, itemId, searchQueryOverride, copyable = false, bare = false, baseDir, teamColorByName: providedTeamColorByName, onTeamClick: providedOnTeamClick, }) { const { t } = useAppTranslation('common'); const [showRaw, setShowRaw] = React.useState(false); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const { isLight } = useTheme(); const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( providedTeamColorByName, providedOnTeamClick ); const isTooLarge = content.length > MAX_MARKDOWN_CHARS; const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; // Only re-render if THIS item has search matches const { searchQuery, searchMatches, currentSearchIndex } = useStore( useShallow((s) => { const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; return { searchQuery: hasMatch ? s.searchQuery : '', searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, }; }) ); // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). // For large content, default to a lightweight raw preview with manual expansion. if (isTooLarge || showRaw) { const shown = content.slice(0, Math.min(rawLimit, content.length)); const isTruncated = shown.length < content.length; return (
    {copyable && !label && ( )} {label && (
    {label} {t('markdown.raw')} {copyable && }
    )} {!label && (
    {t('markdown.rawPreview')}
    )} {isTooLarge && (
    {t('markdown.largeContentNotice', { count: content.length.toLocaleString() })}
    )}
                {shown}
              
    {isTruncated && (
    {t('markdown.showingChars', { shown: shown.length.toLocaleString(), total: content.length.toLocaleString(), })}
    )}
    ); } // Create search context (fresh each render so counter starts at 0) const effectiveQuery = (searchQueryOverride ?? searchQuery).trim(); const effectiveMatches = searchQueryOverride ? [] : searchMatches; const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex; const searchCtx = effectiveQuery && itemId ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) : null; // Local search (Claude logs): use bright highlight for all matches (no "current result" concept). if (searchCtx && searchQueryOverride) { searchCtx.forceAllActive = true; } // Create markdown components with optional search highlighting // When search is active, create fresh each render (match counter is stateful and must start at 0) // useMemo would cache stale closures when parent re-renders without search deps changing const baseComponents = searchCtx ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable) : isLight ? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable) : createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable); // When baseDir is set (editor preview), override img to load local files via IPC const components = baseDir ? { ...baseComponents, img: ({ src, alt }: { src?: string; alt?: string }) => { if (src && isRelativeUrl(src)) { return ; } return {alt; }, } : baseComponents; return (
    {/* Copy button overlay (when no label header) */} {copyable && !label && ( )} {/* Optional header - matches CodeBlockViewer style */} {label && (
    {label} {copyable && }
    )} {/* Show raw toggle for no-label path (skip in bare mode) */} {!label && !bare && (
    )} {/* Markdown content with scroll */}
    {content}
    ); });