import React from 'react'; import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'; 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 { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; import { FileText } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; import { createSearchContext, highlightSearchInChildren, type SearchContext, } from '../searchHighlightUtils'; 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; } // ============================================================================= // Helpers // ============================================================================= /** * Custom URL transform that preserves task:// and mention:// protocols. * react-markdown v10 strips non-standard protocols by default. */ function allowCustomProtocols(url: string): string { if (url.startsWith('task://') || url.startsWith('mention://')) return url; return defaultUrlTransform(url); } /** Resolve a relative path to an absolute path given a base directory */ function resolveRelativePath(relativeSrc: string, baseDir: string): string { const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc; return `${baseDir}/${cleaned}`; } // ============================================================================= // 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 [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 ( [Image: {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 ''; } // ============================================================================= // Component factories // ============================================================================= function createViewerMarkdownComponents( searchCtx: SearchContext | null, isLight = false ): Components { const hl = (children: React.ReactNode): React.ReactNode => searchCtx ? highlightSearchInChildren(children, searchCtx) : children; return { // Headings h1: ({ children }) => (

{hl(children)}

), h2: ({ children }) => (

{hl(children)}

), h3: ({ children }) => (

{hl(children)}

), h4: ({ children }) => (

{hl(children)}

), h5: ({ children }) => (
{hl(children)}
), h6: ({ children }) => (
{hl(children)}
), // Paragraphs p: ({ children }) => (

{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('task://')) { const taskId = href.slice('task://'.length); 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) { return ( {hl(children)} ); } // 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 }) => { // 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 ; } } return (
          {children}
        
); }, // Blockquotes blockquote: ({ children }) => (
{hl(children)}
), // Lists ul: ({ children }) => (
    {children}
), ol: ({ children }) => (
    {children}
), li: ({ children }) => (
  • {hl(children)}
  • ), // Tables table: ({ children }) => (
    {children}
    ), thead: ({ children }) => ( {children} ), th: ({ children }) => ( {hl(children)} ), td: ({ children }) => ( {hl(children)} ), // Horizontal rule hr: () =>
    , }; } /** Default components without search highlighting */ const defaultComponents = createViewerMarkdownComponents(null); // 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 // ============================================================================= export const MarkdownViewer: React.FC = ({ content, maxHeight = 'max-h-96', className = '', label, itemId, searchQueryOverride, copyable = false, bare = false, baseDir, }) => { const [showRaw, setShowRaw] = React.useState(false); const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); const { isLight } = useTheme(); const isTooLarge = content.length > MAX_MARKDOWN_CHARS; const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; // Only subscribe to search store when itemId is provided const { searchQuery, searchMatches, currentSearchIndex } = useStore( useShallow((s) => ({ searchQuery: itemId ? s.searchQuery : '', searchMatches: itemId ? s.searchMatches : [], currentSearchIndex: itemId ? 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} Raw {copyable && }
    )} {!label && (
    Raw preview
    )} {isTooLarge && (
    Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to keep the UI responsive.
    )}
                {shown}
              
    {isTruncated && (
    Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars
    )}
    ); } // 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) : isLight ? createViewerMarkdownComponents(null, true) : defaultComponents; // 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 && ( <> )}
    )} {/* Markdown content with scroll */}
    {content}
    ); };