agent-ecosystem/src/renderer/components/chat/viewers/MarkdownViewer.tsx

1219 lines
37 KiB
TypeScript

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<string, string>;
/** 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<string, string>;
/** 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<string, string>();
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. `<your-name>`, `<info_for_agent>`)
* 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. `<your-name>`, `<info_for_agent>`),
* 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<string | null>(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 (
<span className="inline-flex items-center gap-1 text-xs text-text-muted">
{t('markdown.imageFallback', { label: alt || src })}
</span>
);
}
if (!dataUrl) {
return (
<span className="inline-block size-4 animate-pulse rounded bg-surface-raised align-middle" />
);
}
return <img src={dataUrl} alt={alt || ''} className="my-2 max-w-full rounded" />;
});
/** 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<string, string> = 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 => (
<span className={className} style={style}>
{hl(children)}{' '}
</span>
);
const renderCompactTableSummary = (
node: unknown,
children: React.ReactNode
): React.ReactElement => {
const summary = buildCompactTableSummary(node, children);
return (
<span className="inline" style={{ color: PROSE_MUTED }}>
{summary}{' '}
</span>
);
};
return {
// Headings
h1: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h1>
),
h2: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h2>
),
h3: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<h3
className="mb-2 mt-3 text-base font-semibold first:mt-0"
style={{ color: PROSE_HEADING }}
>
{hl(children)}
</h3>
),
h4: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-semibold', { color: PROSE_HEADING })
) : (
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h4>
),
h5: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-medium', { color: PROSE_HEADING })
) : (
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h5>
),
h6: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'font-medium', { color: PROSE_HEADING })
) : (
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h6>
),
// Paragraphs
p: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, '', { color: PROSE_BODY })
) : (
<p
className="my-2 text-sm leading-relaxed first:mt-0 last:mb-0"
style={{ color: PROSE_BODY }}
>
{hl(children)}
</p>
),
// 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 = (
<span
style={{
backgroundColor: bg,
color: colorSet.text,
borderRadius: '3px',
boxShadow: `0 0 0 1.5px ${bg}`,
fontSize: 'inherit',
cursor: 'default',
}}
>
{children}
</span>
);
if (memberName) {
return (
<MemberHoverCard name={memberName} color={color}>
{badge}
</MemberHoverCard>
);
}
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 (
<button
type="button"
style={badgeStyle}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onTeamClick(teamLabel);
}}
>
<UsersRound size={11} style={{ flexShrink: 0 }} />
{children}
</button>
);
}
return (
<span style={badgeStyle}>
<UsersRound size={11} style={{ flexShrink: 0 }} />
{children}
</span>
);
}
if (href?.startsWith('task://')) {
const parsedTaskLink = parseTaskLinkHref(href);
const taskId = parsedTaskLink?.taskId;
if (!taskId) {
return <>{children}</>;
}
return (
<TaskTooltip taskId={taskId} teamName={parsedTaskLink?.teamName}>
<a
href={href}
className="cursor-pointer font-medium no-underline hover:underline"
style={{ color: PROSE_LINK }}
onClick={(e) => e.preventDefault()}
>
{children}
</a>
</TaskTooltip>
);
}
// Relative file paths — open in built-in editor or copy path
if (href && isRelativeUrl(href)) {
return <FileLink href={href}>{children}</FileLink>;
}
return (
<a
href={href}
className="cursor-pointer no-underline hover:underline"
style={{ color: PROSE_LINK }}
onClick={(e) => {
e.preventDefault();
if (href) {
void api.openExternal(href);
}
}}
>
{children}
</a>
);
},
// Strong/Bold — inline element, no hl()
strong: ({ children }) => (
<strong className="font-semibold" style={{ color: PROSE_HEADING }}>
{children}
</strong>
),
// Emphasis/Italic — inline element, no hl()
em: ({ children }) => (
<em className="italic" style={{ color: PROSE_BODY }}>
{children}
</em>
),
// Strikethrough — inline element, no hl()
del: ({ children }) => (
<del className="line-through" style={{ color: PROSE_BODY }}>
{children}
</del>
),
// 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 (
<code
className={`font-mono text-xs ${codeClassName ?? ''}`.trim()}
style={{ color: COLOR_TEXT }}
>
{lines.map((line, i) => (
<React.Fragment key={i}>
{hl(highlightLine(line, lang))}
{i < lines.length - 1 ? '\n' : null}
</React.Fragment>
))}
</code>
);
}
// Inline code — no hl(); parent block element's hl() descends here
return (
<code
className="break-all rounded px-1.5 py-0.5 font-mono text-xs"
style={{
backgroundColor: PROSE_CODE_BG,
color: PROSE_CODE_TEXT,
}}
>
{children}
</code>
);
},
// Code blocks — intercept mermaid diagrams at the pre level
pre: ({ children, node }) => {
if (isCompactPreview) {
const compactText = extractTextFromReactNode(children).trim();
return (
<code
className="break-all rounded px-1.5 py-0.5 font-mono text-xs"
style={{
backgroundColor: PROSE_CODE_BG,
color: PROSE_CODE_TEXT,
}}
>
{compactText}
</code>
);
}
// 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<string, unknown>)?.className;
if (Array.isArray(cls) && cls.some((c) => String(c) === 'language-mermaid')) {
return <MermaidDiagram code={hastToText(codeEl as unknown as HastNode)} />;
}
}
const codeText = copyCodeBlocks ? extractTextFromReactNode(children).trim() : '';
return (
<pre
className={`my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed ${codeText ? 'group relative' : ''}`.trim()}
style={{
backgroundColor: PROSE_PRE_BG,
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
>
{codeText ? <CopyButton text={codeText} /> : null}
{children}
</pre>
);
},
// Blockquotes
blockquote: ({ children }) =>
isCompactPreview ? (
renderCompactInline(children, 'italic', { color: PROSE_MUTED })
) : (
<blockquote
className="my-3 border-l-4 pl-4 italic"
style={{
borderColor: PROSE_BLOCKQUOTE_BORDER,
color: PROSE_MUTED,
}}
>
{hl(children)}
</blockquote>
),
// Lists
ul: ({ children }) =>
isCompactPreview ? (
<span>{children}</span>
) : (
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ul>
),
ol: ({ children }) =>
isCompactPreview ? (
<span>{children}</span>
) : (
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ol>
),
li: ({ children }) =>
isCompactPreview ? (
<span className="inline" style={{ color: PROSE_BODY }}>
{hl(children)}{' '}
</span>
) : (
<li className="text-sm" style={{ color: PROSE_BODY }}>
{hl(children)}
</li>
),
// Tables
table: ({ children, node }) =>
isCompactPreview ? (
renderCompactTableSummary(node, children)
) : (
<div className="my-3 overflow-x-auto">
<table
className="min-w-full border-collapse text-sm"
style={{ borderColor: PROSE_TABLE_BORDER }}
>
{children}
</table>
</div>
),
thead: ({ children }) =>
isCompactPreview ? (
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
) : (
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
),
th: ({ children }) =>
isCompactPreview ? (
<th
className="px-3 py-1.5 text-left font-semibold"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_HEADING,
}}
>
{hl(children)}
</th>
) : (
<th
className="px-3 py-2 text-left font-semibold"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_HEADING,
}}
>
{hl(children)}
</th>
),
td: ({ children }) =>
isCompactPreview ? (
<td
className="px-3 py-1.5"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_BODY,
}}
>
{hl(children)}
</td>
) : (
<td
className="px-3 py-2"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_BODY,
}}
>
{hl(children)}
</td>
),
// Horizontal rule
hr: () =>
isCompactPreview ? (
<span className="mx-1" style={{ color: PROSE_TABLE_BORDER }}>
·
</span>
) : (
<hr className="my-4" style={{ borderColor: PROSE_TABLE_BORDER }} />
),
};
}
// 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<string, string>,
providedOnTeamClick?: (teamName: string) => void
): {
teamColorByName: ReadonlyMap<string, string>;
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<string, string>();
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<CompactMarkdownPreviewProps> = 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 (
<div className={`min-w-0 overflow-hidden ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={REHYPE_PLUGINS_NO_HIGHLIGHT}
components={components}
urlTransform={allowCustomProtocols}
allowElement={isAllowedElement}
unwrapDisallowed
>
{content}
</ReactMarkdown>
</div>
);
}
);
export const MarkdownViewer: React.FC<MarkdownViewerProps> = 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 (
<div
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
style={
bare
? undefined
: {
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}
}
>
{copyable && !label && (
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
)}
{label && (
<div
className="flex items-center gap-2 px-3 py-2"
style={{
backgroundColor: CODE_HEADER_BG,
borderBottom: `1px solid ${CODE_BORDER}`,
}}
>
<FileText className="size-4 shrink-0" style={{ color: COLOR_TEXT_MUTED }} />
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
{label}
</span>
<span className="ml-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
{t('markdown.raw')}
</span>
<span className="flex-1" />
<button
type="button"
className="text-xs underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={isTooLarge ? t('markdown.largeContentTitle') : t('markdown.renderMarkdown')}
>
{t('markdown.renderMarkdown')}
</button>
{copyable && <CopyButton text={content} inline />}
</div>
)}
{!label && (
<div
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: COLOR_TEXT_MUTED }}
>
<span>{t('markdown.rawPreview')}</span>
<button
type="button"
className="underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={isTooLarge ? t('markdown.largeContentTitle') : t('markdown.renderMarkdown')}
>
{t('markdown.renderMarkdown')}
</button>
</div>
)}
{isTooLarge && (
<div className="px-3 pb-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
{t('markdown.largeContentNotice', { count: content.length.toLocaleString() })}
</div>
)}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<pre
className="min-w-0 whitespace-pre-wrap break-words p-4 font-mono text-xs leading-relaxed"
style={{ color: PROSE_BODY }}
>
{shown}
</pre>
{isTruncated && (
<div className="flex items-center justify-between gap-2 px-4 pb-4 text-xs">
<span style={{ color: COLOR_TEXT_MUTED }}>
{t('markdown.showingChars', {
shown: shown.length.toLocaleString(),
total: content.length.toLocaleString(),
})}
</span>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit((v) => Math.min(content.length, v * 2))}
>
{t('markdown.showMore')}
</button>
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit(content.length)}
>
{t('markdown.showAll')}
</button>
</div>
</div>
)}
</div>
</div>
);
}
// 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 <LocalImage src={src} alt={alt} baseDir={baseDir} />;
}
return <img src={src} alt={alt || ''} className="my-2 max-w-full rounded" />;
},
}
: baseComponents;
return (
<div
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
style={
bare
? undefined
: {
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}
}
>
{/* Copy button overlay (when no label header) */}
{copyable && !label && (
<CopyButton text={content} bgColor={bare ? 'transparent' : undefined} />
)}
{/* Optional header - matches CodeBlockViewer style */}
{label && (
<div
className="flex items-center gap-2 px-3 py-2"
style={{
backgroundColor: CODE_HEADER_BG,
borderBottom: `1px solid ${CODE_BORDER}`,
}}
>
<FileText className="size-4 shrink-0" style={{ color: COLOR_TEXT_MUTED }} />
<span className="text-sm font-medium" style={{ color: COLOR_TEXT_SECONDARY }}>
{label}
</span>
<span className="flex-1" />
<button
type="button"
className="text-xs underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(true)}
title={t('markdown.showRaw')}
>
{t('markdown.showRaw')}
</button>
{copyable && <CopyButton text={content} inline />}
</div>
)}
{/* Show raw toggle for no-label path (skip in bare mode) */}
{!label && !bare && (
<div
className="flex items-center justify-between px-3 py-1 text-xs"
style={{ color: COLOR_TEXT_MUTED }}
>
<span />
<button
type="button"
className="underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(true)}
title={t('markdown.showRaw')}
>
{t('markdown.showRaw')}
</button>
</div>
)}
{/* Markdown content with scroll */}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<div className="min-w-0 break-words p-2">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
components={components}
urlTransform={allowCustomProtocols}
allowElement={isAllowedElement}
unwrapDisallowed
>
{content}
</ReactMarkdown>
</div>
</div>
</div>
);
});