1219 lines
37 KiB
TypeScript
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>
|
|
);
|
|
});
|