agent-ecosystem/src/renderer/components/chat/viewers/MarkdownViewer.tsx
iliya a29d8403d6 refactor: update comment formatting and notification handling in maintenance and tasks
- Modified comment notification formatting in tasks.js to enhance clarity by using bold text for task references.
- Updated the automated comment notification detection in maintenance.js to align with the new formatting.
- Adjusted test cases to reflect the updated comment structure for consistency in notifications.
2026-03-14 22:10:16 +02:00

823 lines
27 KiB
TypeScript

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 type { SearchMatch } from '@renderer/store/types';
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 {
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;
/** 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: Array<{ teamName?: string; displayName?: string; color?: string }> = [];
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
const EMPTY_SEARCH_MATCHES: SearchMatch[] = [];
const NOOP_TEAM_CLICK = (): void => undefined;
// =============================================================================
// Helpers
// =============================================================================
/**
* 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 */
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<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">
[Image: {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 '';
}
// =============================================================================
// Component factories
// =============================================================================
function createViewerMarkdownComponents(
searchCtx: SearchContext | null,
isLight = false,
teamColorByName: ReadonlyMap<string, string> = new Map(),
onTeamClick?: (teamName: string) => void
): Components {
const hl = (children: React.ReactNode): React.ReactNode =>
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
return {
// Headings
h1: ({ children }) => (
<h1 className="mb-2 mt-4 text-xl font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h1>
),
h2: ({ children }) => (
<h2 className="mb-2 mt-4 text-lg font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 mt-3 text-base font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h3>
),
h4: ({ children }) => (
<h4 className="mb-1 mt-3 text-sm font-semibold first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h4>
),
h5: ({ children }) => (
<h5 className="mb-1 mt-2 text-sm font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h5>
),
h6: ({ children }) => (
<h6 className="mb-1 mt-2 text-xs font-medium first:mt-0" style={{ color: PROSE_HEADING }}>
{hl(children)}
</h6>
),
// Paragraphs
p: ({ children }) => (
<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) {
return (
<code
className={`font-mono text-xs ${codeClassName ?? ''}`.trim()}
style={{ color: COLOR_TEXT }}
>
{hl(children)}
</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 }) => {
// 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)} />;
}
}
return (
<pre
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
style={{
backgroundColor: PROSE_PRE_BG,
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
>
{children}
</pre>
);
},
// Blockquotes
blockquote: ({ children }) => (
<blockquote
className="my-3 border-l-4 pl-4 italic"
style={{
borderColor: PROSE_BLOCKQUOTE_BORDER,
color: PROSE_MUTED,
}}
>
{hl(children)}
</blockquote>
),
// Lists
ul: ({ children }) => (
<ul className="my-2 list-disc space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ul>
),
ol: ({ children }) => (
<ol className="my-2 list-decimal space-y-1 pl-5" style={{ color: PROSE_BODY }}>
{children}
</ol>
),
li: ({ children }) => (
<li className="text-sm" style={{ color: PROSE_BODY }}>
{hl(children)}
</li>
),
// Tables
table: ({ 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 }) => (
<thead style={{ backgroundColor: PROSE_TABLE_HEADER_BG }}>{children}</thead>
),
th: ({ children }) => (
<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 }) => (
<td
className="px-3 py-2"
style={{
border: `1px solid ${PROSE_TABLE_BORDER}`,
color: PROSE_BODY,
}}
>
{hl(children)}
</td>
),
// Horizontal rule
hr: () => <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
// =============================================================================
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
content,
maxHeight = 'max-h-96',
className = '',
label,
itemId,
searchQueryOverride,
copyable = false,
bare = false,
baseDir,
teamColorByName: providedTeamColorByName,
onTeamClick: providedOnTeamClick,
}) => {
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme();
const teams = useStore((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]);
const teamColorByName =
providedTeamColorByName ?? fallbackTeamColorByName ?? EMPTY_TEAM_COLOR_MAP;
const onTeamClick = providedOnTeamClick ?? openTeamTab;
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 : EMPTY_SEARCH_MATCHES,
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 (
<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 }}>
Raw
</span>
<span className="flex-1" />
<button
type="button"
className="text-xs underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</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>Raw preview</span>
<button
type="button"
className="underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
title={
isTooLarge
? 'Large content is shown as raw to prevent UI freeze'
: 'Render markdown'
}
>
Render markdown
</button>
</div>
)}
{isTooLarge && (
<div className="px-3 pb-2 text-[11px]" style={{ color: COLOR_TEXT_MUTED }}>
Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to
keep the UI responsive.
</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 }}>
Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars
</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))}
>
Show more
</button>
<button
type="button"
className="rounded border px-2 py-1"
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit(content.length)}
>
Show all
</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)
: isLight
? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick)
: createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick);
// 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>
{copyable && (
<>
<span className="flex-1" />
<CopyButton text={content} inline />
</>
)}
</div>
)}
{/* Markdown content with scroll */}
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
<div className="min-w-0 break-words p-4">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
components={components}
urlTransform={allowCustomProtocols}
allowElement={isAllowedElement}
unwrapDisallowed
>
{content}
</ReactMarkdown>
</div>
</div>
</div>
);
};