Files
openchamber/packages/ui/src/components/chat/MarkdownRendererImpl.tsx
T
Bohdan Triapitsyn 51c8d52ab5 perf(ui): improve VS Code chat session switching
Improve chat session switching and history pagination, with most of the aggressive limits scoped to the VS Code webview where the freezes were observed.

Session history loading and pagination:

- Reduce the VS Code message page size to 30 records so switching sessions does not immediately hydrate large histories into the webview.

- Keep manual Load older messages in VS Code fixed at 30 records per request instead of growing the request size over time.

- Add a bounded VS Code initial-tail expansion path from 30 to 50, 80, and 120 records only when the initial page has no user-message turn boundary, preventing large final turns from rendering as an empty chat.

- Lower the normal web message page size from 200 to 150 for a mild shared optimization without adopting the aggressive VS Code limits.

- Make session pagination metadata reactive per session so ChatContainer receives cursor updates from materialization and reconnect paths without requiring a switch away and back.

- Write pagination metadata before publishing newly materialized messages so the first render sees the correct has-more state.

- Store cursor information from direct materialization and reconnect message fetches in the shared session prefetch metadata cache.

VS Code cache and memory pressure reductions:

- Use a shared per-directory session recency map so cache eviction is based on app-level recency instead of whichever useSync instance happened to run.

- Limit VS Code warm session cache retention to 4 sessions and evict heavy inactive message caches after switching away from a large session.

- Disable sidebar session prefetch in VS Code because warming extra sessions was increasing webview memory and GC pressure during navigation.

- Remove dropdown background message prefetch so opening the switcher does not start additional session materialization work.

- Drop cached session-message-record snapshots when evicting session data so stale derived records do not remain after the raw session cache is cleared.

- Add bounded LRU caching for session message record snapshots, with much smaller VS Code limits and a VS Code cap that avoids caching snapshots above 30 messages.

- Bound the turn-window model cache in VS Code and avoid caching turn models for sessions above the VS Code message-page size.

Chat render-path reductions:

- Reuse ChatContainer's already-materialized message records in plan detection instead of adding a second active-session message subscription.

- Add a no-op guard when marking session plan availability so repeated detections do not create new Map references and fan out renders.

- Add no-op guards for session switcher and dropdown open state updates to avoid unnecessary store updates and renders.

- Convert several session-specific hooks to useSyncExternalStore with empty-session no-subscribe behavior so empty IDs do not subscribe to broad store updates.

- Remount the chat viewport when the current session changes, isolating per-session viewport and list state.

- Change the virtualized message-list fallback to render only a tail window when the virtualizer has not produced rows yet, instead of rendering an entire large history.

VS Code layout and header improvements:

- Remove the broad useSessions subscription from the VS Code layout header path and subscribe only to the active session title and initial-session existence.

- Unmount the compact VS Code session sidebar when the user is in chat view instead of keeping the hidden session list mounted and subscribed.

- Compute the latest assistant model and latest context-token usage in a single reverse scan of current-session messages instead of scanning the same list twice.

- Remove switcher git-status warmup work so the switcher reads already-loaded branch labels without starting extra background git status requests.

Markdown and file-reference safeguards:

- Skip expensive syntax highlighting for very large code blocks, with a 200-line cap in VS Code and a softer 1200-line cap in web.

- Add an LRU cap to file-reference stat lookups so the cache cannot grow without bound across many rendered messages.

- Limit the number of file references annotated per render to 40 in VS Code and 200 in web to prevent large assistant outputs from spawning too many stat checks.

- Clear file-link annotations when file-reference mode is disabled so stale attributes and handlers do not remain on previously annotated nodes.

Assistant-message action and preview reductions:

- Skip preview URL scanning on VS Code, mobile, and mini-chat surfaces so assistant text and tool output are not scanned where the preview action is unavailable.

- Skip Save-as-Plan project lookup on VS Code, mini-chat, and mobile surfaces.

- Hide Save-as-Plan and Start MultiRun assistant-message actions on VS Code, mini-chat, and mobile surfaces.

- Resolve the current session directory on demand for assistant actions instead of subscribing each assistant message to the full session list.

Tool and task rendering optimizations:

- Prefer finalized task metadata summaries without fetching child-session messages when the summary is already present.

- Avoid polling or final-fetching task child sessions once a final metadata summary is available.

- Use VS Code-specific task child fetch limits of 30 records for initial, active, and idle fetches.

- Parse diff stats by scanning patch text line-by-line instead of splitting large patches into arrays.

- Count write-tool lines by scanning content instead of allocating a split array for large files.

- Avoid trimming large patch strings just to test whether they contain content.

- Memoize diff and write statistics so unchanged tool parts do not recalculate them on every render.

VS Code bridge improvements:

- Return JSON and text proxy responses through the VS Code bridge as bodyText instead of base64 so the webview avoids synchronous base64 decoding for common API responses.

- Keep binary responses on the base64 path while making bodyBase64 optional in the bridge contract.

- Strip content-length, content-encoding, and transfer-encoding headers from proxied responses because the bridge reconstructs the Response body.

Validation:

- bun run type-check

- bun run lint

- bun run vscode:build
2026-05-21 15:45:44 +03:00

1837 lines
59 KiB
TypeScript

import React from 'react';
import 'katex/dist/katex.min.css';
import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import ReactMarkdown from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { marked, type Tokens } from 'marked';
import remend from 'remend';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
import { getExternalFaviconUrl, isExternalHttpUrl, isLoopbackHttpUrl, openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getDefaultTheme } from '@/lib/theme/themes';
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
import type { ToolPopupContent } from './message/types';
import { useUIStore } from '@/stores/useUIStore';
import { useDeviceInfo } from '@/lib/device';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { EditorAPI } from '@/lib/api/types';
import { isVSCodeRuntime } from '@/lib/desktop';
const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem();
const fallbackLight = getDefaultTheme(false);
const fallbackDark = getDefaultTheme(true);
return themeSystem?.currentTheme
?? (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
? fallbackDark
: fallbackLight);
};
const useExternalLinkInteractions = ({
containerRef,
enabled,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
enabled?: boolean;
}) => {
React.useEffect(() => {
if (enabled === false) {
return;
}
const container = containerRef.current;
if (!container) {
return;
}
const handleClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
return;
}
const href = anchor.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) {
return;
}
event.preventDefault();
event.stopPropagation();
void openExternalUrl(href);
};
container.addEventListener('click', handleClick);
return () => {
container.removeEventListener('click', handleClick);
};
}, [containerRef, enabled]);
};
const ExternalLinkFavicon: React.FC<{ href: string }> = ({ href }) => {
const [failed, setFailed] = React.useState(false);
const faviconUrl = React.useMemo(() => getExternalFaviconUrl(href), [href]);
if (!faviconUrl || failed) {
return null;
}
return (
<span className="mr-1 inline-flex size-[18px] items-center justify-center rounded border border-[var(--border)] bg-[var(--interactive-hover)] align-middle">
<img
src={faviconUrl}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
className="size-3.5 rounded-sm"
onError={() => setFailed(true)}
/>
</span>
);
};
// Table utility functions
const extractTableData = (tableEl: HTMLTableElement): { headers: string[]; rows: string[][] } => {
const headers: string[] = [];
const rows: string[][] = [];
const thead = tableEl.querySelector('thead');
if (thead) {
const headerCells = thead.querySelectorAll('th');
headerCells.forEach(cell => headers.push(cell.innerText.trim()));
}
const tbody = tableEl.querySelector('tbody');
if (tbody) {
const rowEls = tbody.querySelectorAll('tr');
rowEls.forEach(row => {
const cells = row.querySelectorAll('td');
const rowData: string[] = [];
cells.forEach(cell => rowData.push(cell.innerText.trim()));
rows.push(rowData);
});
}
return { headers, rows };
};
const tableToCSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
const escapeCell = (cell: string): string => {
if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) {
return `"${cell.replace(/"/g, '""')}"`;
}
return cell;
};
const lines: string[] = [];
if (headers.length > 0) {
lines.push(headers.map(escapeCell).join(','));
}
rows.forEach(row => lines.push(row.map(escapeCell).join(',')));
return lines.join('\n');
};
const tableToTSV = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
const escapeCell = (cell: string): string => {
return cell.replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
};
const lines: string[] = [];
if (headers.length > 0) {
lines.push(headers.map(escapeCell).join('\t'));
}
rows.forEach(row => lines.push(row.map(escapeCell).join('\t')));
return lines.join('\n');
};
const tableToMarkdown = ({ headers, rows }: { headers: string[]; rows: string[][] }): string => {
if (headers.length === 0) return '';
const escapeCell = (cell: string): string => {
return cell.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
};
const lines: string[] = [];
lines.push(`| ${headers.map(escapeCell).join(' | ')} |`);
lines.push(`| ${headers.map(() => '---').join(' | ')} |`);
rows.forEach(row => {
const paddedRow = headers.map((_, i) => escapeCell(row[i] || ''));
lines.push(`| ${paddedRow.join(' | ')} |`);
});
return lines.join('\n');
};
const downloadFile = (filename: string, content: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Table copy button with dropdown
const TableCopyButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
const { t } = useI18n();
const [copied, setCopied] = React.useState(false);
const [showMenu, setShowMenu] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleCopy = async (format: 'csv' | 'tsv') => {
const tableEl = tableRef.current?.querySelector('table');
if (!tableEl) return;
const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToTSV(data);
try {
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([content], { type: 'text/plain' }),
'text/html': new Blob([tableEl.outerHTML], { type: 'text/html' }),
}),
]);
setCopied(true);
setShowMenu(false);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
const fallbackResult = await copyTextToClipboard(content);
if (fallbackResult.ok) {
setCopied(true);
setShowMenu(false);
setTimeout(() => setCopied(false), 2000);
return;
}
console.error('Failed to copy table:', err);
}
};
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={t('markdownRenderer.table.actions.copyTitle')}
>
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
</button>
{showMenu && (
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-none">
<button
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
onClick={() => handleCopy('csv')}
>
CSV
</button>
<button
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
onClick={() => handleCopy('tsv')}
>
TSV
</button>
</div>
)}
</div>
);
};
// Table download button with dropdown
const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement | null> }> = ({ tableRef }) => {
const { t } = useI18n();
const [showMenu, setShowMenu] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleDownload = (format: 'csv' | 'markdown') => {
const tableEl = tableRef.current?.querySelector('table');
if (!tableEl) return;
const data = extractTableData(tableEl);
const content = format === 'csv' ? tableToCSV(data) : tableToMarkdown(data);
const filename = format === 'csv' ? 'table.csv' : 'table.md';
const mimeType = format === 'csv' ? 'text/csv' : 'text/markdown';
downloadFile(filename, content, mimeType);
setShowMenu(false);
toast.success(t('markdownRenderer.table.toast.downloadedAsFormat', { format: format.toUpperCase() }));
};
return (
<div className="relative" ref={menuRef}>
<button
onClick={() => setShowMenu(!showMenu)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={t('markdownRenderer.table.actions.downloadTitle')}
>
<Icon name="download" className="size-3.5" />
</button>
{showMenu && (
<div className="absolute top-full right-0 z-10 mt-1 min-w-[100px] overflow-hidden rounded-md border border-border bg-background shadow-none">
<button
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
onClick={() => handleDownload('csv')}
>
CSV
</button>
<button
className="w-full px-3 py-1.5 text-left text-sm transition-colors hover:bg-interactive-hover/40"
onClick={() => handleDownload('markdown')}
>
Markdown
</button>
</div>
)}
</div>
);
};
// Table wrapper with custom controls
const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
const tableRef = React.useRef<HTMLDivElement>(null);
const { isMobile, isTablet } = useDeviceInfo();
const alwaysShowActions = isMobile || isTablet;
return (
<div className="group my-4 flex flex-col space-y-2" data-markdown="table-wrapper" ref={tableRef}>
<div className={cn(
"flex items-center justify-end gap-1 transition-opacity",
alwaysShowActions ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<TableCopyButton tableRef={tableRef} />
<TableDownloadButton tableRef={tableRef} />
</div>
<div className="overflow-x-auto rounded-lg border border-border/80 bg-[var(--surface-elevated)]">
<table className={cn('w-full border-collapse text-sm', className)} data-markdown="table">
{children}
</table>
</div>
</div>
);
};
const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => {
const { t } = useI18n();
const currentTheme = useCurrentMermaidTheme();
const { isMobile, isTablet } = useDeviceInfo();
const [copied, setCopied] = React.useState(false);
const [downloaded, setDownloaded] = React.useState(false);
const svg = React.useMemo(() => {
if (mode !== 'svg') return '';
try {
return renderMermaidSVG(source, {
bg: currentTheme.colors.surface.elevated,
fg: currentTheme.colors.surface.foreground,
line: currentTheme.colors.interactive.border,
accent: currentTheme.colors.primary.base,
muted: currentTheme.colors.surface.mutedForeground,
surface: currentTheme.colors.surface.muted,
border: currentTheme.colors.interactive.border,
transparent: true,
font: 'IBM Plex Sans, sans-serif',
});
} catch {
return '';
}
}, [currentTheme, mode, source]);
const ascii = React.useMemo(() => {
if (mode !== 'ascii') return '';
try {
return renderMermaidASCII(source);
} catch {
return '';
}
}, [mode, source]);
const copyVisibilityClass = isMobile || isTablet ? 'opacity-100' : 'opacity-0 group-hover:opacity-100';
const handleCopyAscii = async (asciiText: string) => {
if (!asciiText) return;
const result = await copyTextToClipboard(asciiText);
if (result.ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleCopyMermaidSource = async () => {
if (!source) return;
const result = await copyTextToClipboard(source);
if (result.ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleDownloadSvg = () => {
if (!svg) return;
try {
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `diagram-${Date.now()}.svg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setDownloaded(true);
setTimeout(() => setDownloaded(false), 2000);
} catch {
toast.error(t('markdownRenderer.mermaid.toast.downloadFailed'));
}
};
if (mode === 'ascii') {
const asciiText = ascii || source;
return (
<div data-markdown="mermaid-block" className="group">
<div data-markdown="mermaid-scroll">
<pre data-markdown="mermaid-ascii">{asciiText}</pre>
</div>
<div
className={cn(
'absolute top-1 right-2 transition-opacity',
copyVisibilityClass,
)}
>
<button
onClick={() => handleCopyAscii(asciiText)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={t('markdownRenderer.mermaid.actions.copyTitle')}
>
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
</button>
</div>
</div>
);
}
if (!svg) {
return (
<div data-markdown="mermaid-block" className="group">
<div data-markdown="mermaid-scroll">
<pre data-markdown="mermaid-ascii">{source}</pre>
</div>
<div
className={cn(
'absolute top-1 right-2 transition-opacity',
copyVisibilityClass,
)}
>
<button
onClick={() => handleCopyAscii(source)}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={t('markdownRenderer.mermaid.actions.copyTitle')}
>
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
</button>
</div>
</div>
);
}
return (
<div data-markdown="mermaid-block" className="group">
<div data-markdown="mermaid-scroll">
<div data-markdown="mermaid" dangerouslySetInnerHTML={{ __html: svg }} />
</div>
<div
className={cn(
'absolute top-1 right-2 flex items-center gap-1 transition-opacity',
copyVisibilityClass,
)}
>
<button
onClick={handleCopyMermaidSource}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={t('markdownRenderer.mermaid.actions.copySourceTitle')}
>
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
</button>
<button
onClick={handleDownloadSvg}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={t('markdownRenderer.mermaid.actions.downloadSvgTitle')}
>
{downloaded ? <Icon name="check" className="size-3.5" /> : <Icon name="download" className="size-3.5" />}
</button>
</div>
</div>
);
};
type MermaidControlOptions = {
download: boolean;
copy: boolean;
fullscreen: boolean;
panZoom: boolean;
};
const extractMermaidBlocks = (markdown: string): string[] => {
if (!markdown.includes('mermaid')) return [];
const blocks: string[] = [];
const regex = /(?:^|\r?\n)(`{3,}|~{3,})mermaid[^\n\r]*\r?\n([\s\S]*?)\r?\n\1(?=\r?\n|$)/gi;
let match: RegExpExecArray | null = regex.exec(markdown);
while (match) {
const block = (match[2] ?? '').replace(/\s+$/, '');
blocks.push(block);
match = regex.exec(markdown);
}
return blocks;
};
const stripLeadingFrontmatter = (markdown: string): string => {
const frontmatterMatch = markdown.match(
/^(?:\uFEFF)?(---|\+\+\+)[^\S\r\n]*\r?\n[\s\S]*?\r?\n\1[^\S\r\n]*(?:\r?\n|$)/,
);
if (!frontmatterMatch) {
return markdown;
}
return markdown.slice(frontmatterMatch[0].length);
};
export type MarkdownVariant = 'assistant' | 'tool' | 'reasoning';
type MarkdownStreamBlock = {
key: string;
raw: string;
src: string;
mode: 'full' | 'live';
};
const hasReferenceDefinitions = (text: string): boolean => {
return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text);
};
const hasOpenFence = (raw: string): boolean => {
const match = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/);
if (!match) return false;
const marker = match[1];
if (!marker) return false;
const char = marker[0];
const size = marker.length;
const last = raw.trimEnd().split('\n').at(-1)?.trim() ?? '';
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last);
};
const healMarkdown = (text: string): string => {
return remend(text, { linkMode: 'text-only' });
};
const fnv1a32 = (input: string): string => {
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i);
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
}
return hash.toString(16);
};
const buildMarkdownCacheKey = (baseKey: string, raw: string, index: number, mode: 'full' | 'live'): string => {
const sample = raw.length > 400 ? `${raw.slice(0, 200)}${raw.slice(-200)}` : raw;
return `${baseKey}:${index}:${mode}:${raw.length}:${fnv1a32(sample)}`;
};
const streamMarkdownBlocks = (text: string, live: boolean, baseKey: string): MarkdownStreamBlock[] => {
if (!live) {
return [{
key: buildMarkdownCacheKey(baseKey, text, 0, 'full'),
raw: text,
src: text,
mode: 'full',
}];
}
const healed = healMarkdown(text);
if (hasReferenceDefinitions(text)) {
return [{
key: buildMarkdownCacheKey(baseKey, text, 0, 'live'),
raw: text,
src: healed,
mode: 'live',
}];
}
const tokens = marked.lexer(text);
const blocks: MarkdownStreamBlock[] = [];
let blockIndex = 0;
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index] as Tokens.Generic;
if (token.type === 'space') {
continue;
}
const raw = token.raw ?? '';
const isLast = index === tokens.length - 1 || tokens.slice(index + 1).every((nextToken) => nextToken.type === 'space');
const mode: 'full' | 'live' = isLast ? 'live' : 'full';
const src = isLast && token.type === 'code' && hasOpenFence(raw)
? raw
: healMarkdown(raw);
blocks.push({
key: buildMarkdownCacheKey(baseKey, raw, blockIndex, mode),
raw,
src,
mode,
});
blockIndex += 1;
}
if (blocks.length === 0) {
return [{
key: buildMarkdownCacheKey(baseKey, text, 0, 'live'),
raw: text,
src: healed,
mode: 'live',
}];
}
return blocks;
};
const useStableMarkdownBlocks = (text: string, live: boolean, baseKey: string): MarkdownStreamBlock[] => {
const previousRef = React.useRef<MarkdownStreamBlock[]>([]);
return React.useMemo(() => {
const nextBlocks = streamMarkdownBlocks(text, live, baseKey);
const previousBlocks = previousRef.current;
const stabilized = nextBlocks.map((block, index) => {
const previous = previousBlocks[index];
if (previous && previous.key === block.key && previous.src === block.src) {
return previous;
}
return block;
});
const unchanged = stabilized.length === previousBlocks.length
&& stabilized.every((block, index) => block === previousBlocks[index]);
if (unchanged) {
return previousBlocks;
}
previousRef.current = stabilized;
return stabilized;
}, [baseKey, live, text]);
};
const extractCodeText = (children: React.ReactNode): string => {
if (typeof children === 'string') return children;
if (Array.isArray(children)) {
return children.map((child) => extractCodeText(child)).join('');
}
if (React.isValidElement(children)) {
return extractCodeText((children.props as { children?: React.ReactNode }).children);
}
return '';
};
const getCodeLanguage = (className: string | undefined): string => {
const match = className?.match(/language-([\w-]+)/);
return match?.[1]?.toLowerCase() ?? 'text';
};
const decodeHtmlEntities = (value: string): string => {
let decoded = value;
for (let i = 0; i < 3; i += 1) {
const next = decoded
.replace(/&quot;/g, '"')
.replace(/&#34;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
if (next === decoded) {
return decoded;
}
decoded = next;
}
return decoded;
};
const normalizeCodeBlockText = (code: string, language: string): string => {
if (!['json', 'jsonc', 'json5'].includes(language)) {
return code;
}
if (!/&(quot|#34|amp;quot|lt|gt|amp|apos|#39);/.test(code)) {
return code;
}
return decodeHtmlEntities(code);
};
const CODE_HIGHLIGHT_SETTLE_MS = 300;
const CODE_HIGHLIGHT_LINE_LIMIT = 1200;
const VSCODE_CODE_HIGHLIGHT_LINE_LIMIT = 200;
const CODE_SHARED_STYLE: React.CSSProperties = {
margin: 0,
background: 'transparent',
padding: 0,
fontSize: 'var(--text-code)',
lineHeight: 'var(--markdown-code-block-line-height)',
};
const exceedsLineLimit = (value: string, limit: number): boolean => {
let lineCount = 1;
for (let index = 0; index < value.length; index += 1) {
if (value.charCodeAt(index) === 10) {
lineCount += 1;
if (lineCount > limit) {
return true;
}
}
}
return false;
};
const getCodeHighlightLineLimit = (): number => (
isVSCodeRuntime() ? VSCODE_CODE_HIGHLIGHT_LINE_LIMIT : CODE_HIGHLIGHT_LINE_LIMIT
);
const downloadTextFile = (content: string, filename: string, mimeType: string) => {
if (typeof window === 'undefined') {
return;
}
try {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
// Best-effort; callers can optionally toast.
}
};
const MarkdownCodeBlock: React.FC<{
code: string;
language: string;
syntaxTheme: { [key: string]: React.CSSProperties };
}> = ({ code, language, syntaxTheme }) => {
const [copied, setCopied] = React.useState(false);
const [highlight, setHighlight] = React.useState(true);
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code');
const prevCodeRef = React.useRef<string>(code);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const { isMobile, isTablet } = useDeviceInfo();
const skipHighlight = exceedsLineLimit(code, getCodeHighlightLineLimit());
const canPreview = language === 'html' || language === 'htm';
React.useEffect(() => {
if (!canPreview && viewMode !== 'code') {
setViewMode('code');
}
}, [canPreview, viewMode]);
// Defer Prism highlighting while code is actively streaming.
// Initial mount renders highlighted immediately (plays nice with finalized blocks).
React.useEffect(() => {
if (prevCodeRef.current === code) return;
prevCodeRef.current = code;
if (timerRef.current) clearTimeout(timerRef.current);
setHighlight(false);
timerRef.current = setTimeout(() => {
setHighlight(true);
timerRef.current = null;
}, CODE_HIGHLIGHT_SETTLE_MS);
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [code]);
const handleCopy = React.useCallback(async () => {
const result = await copyTextToClipboard(code);
if (!result.ok) return;
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
}, [code]);
const handleDownload = React.useCallback(() => {
if (!canPreview) {
return;
}
const safeSuffix = Date.now().toString(36);
downloadTextFile(code, `preview-${safeSuffix}.html`, 'text/html;charset=utf-8');
}, [canPreview, code]);
return (
<div data-component="markdown-code" className="my-4 group overflow-hidden rounded-2xl border border-border/80 bg-[var(--surface-elevated)]">
<div className="flex items-center justify-between border-b border-border/70 px-3 py-1.5">
<span className="font-mono text-[13px] text-muted-foreground">{language}</span>
<div className={cn(
"flex items-center gap-1 transition-opacity",
isMobile || isTablet ? "opacity-100" : "opacity-100 md:opacity-0 md:group-hover:opacity-100"
)}>
{canPreview ? (
<button
type="button"
onClick={() => setViewMode((mode) => (mode === 'preview' ? 'code' : 'preview'))}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={viewMode === 'preview' ? 'Show code' : 'Preview'}
aria-pressed={viewMode === 'preview'}
aria-label={viewMode === 'preview' ? 'Show code' : 'Preview HTML'}
>
{viewMode === 'preview' ? <Icon name="code" className="size-3.5" /> : <Icon name="eye" className="size-3.5" />}
</button>
) : null}
{canPreview ? (
<button
type="button"
onClick={handleDownload}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title="Download HTML"
aria-label="Download HTML"
>
<Icon name="download" className="size-3.5" />
</button>
) : null}
<button
type="button"
onClick={() => { void handleCopy(); }}
className="p-1 rounded hover:bg-interactive-hover/60 text-muted-foreground hover:text-foreground transition-colors"
title={copied ? 'Copied' : 'Copy code'}
aria-label={copied ? 'Copied' : 'Copy code'}
>
{copied ? <Icon name="check" className="size-3.5" /> : <Icon name="file-copy" className="size-3.5" />}
</button>
</div>
</div>
{canPreview && viewMode === 'preview' ? (
<div className="h-[320px] md:h-[420px] bg-background">
<iframe
srcDoc={code}
title="HTML preview"
className="h-full w-full border-0"
sandbox="allow-scripts allow-forms"
/>
</div>
) : (
<div className="px-3 py-2.5">
{highlight && !skipHighlight ? (
<SyntaxHighlighter
language={language}
style={syntaxTheme}
customStyle={CODE_SHARED_STYLE}
codeTagProps={{ style: CODE_SHARED_STYLE }}
PreTag="pre"
>
{code}
</SyntaxHighlighter>
) : (
<pre style={CODE_SHARED_STYLE}>
<code style={CODE_SHARED_STYLE}>{code}</code>
</pre>
)}
</div>
)}
</div>
);
};
const buildMarkdownComponents = ({
syntaxTheme,
onPreviewLoopback,
previewLabel,
previewTitle,
}: {
syntaxTheme: { [key: string]: React.CSSProperties };
onPreviewLoopback?: (url: string) => void;
previewLabel?: string;
previewTitle?: string;
}): Components => ({
table({ children, ...props }) {
return <TableWrapper className={props.className}>{children}</TableWrapper>;
},
h1({ children, ...props }) {
return <h1 {...props} className={cn('typography-markdown-h1 mt-4 mb-2 text-[var(--markdown-heading1,var(--primary))] font-semibold', props.className)}>{children}</h1>;
},
h2({ children, ...props }) {
return <h2 {...props} className={cn('typography-markdown-h2 mt-3.5 mb-1.5 text-[var(--markdown-heading2,var(--primary))] font-semibold', props.className)}>{children}</h2>;
},
h3({ children, ...props }) {
return <h3 {...props} className={cn('typography-markdown-h3 mt-3 mb-1 text-[var(--markdown-heading3,var(--primary))] font-semibold', props.className)}>{children}</h3>;
},
h4({ children, ...props }) {
return <h4 {...props} className={cn('typography-markdown-h4 mt-2.5 mb-1 text-[var(--markdown-heading4,var(--foreground))] font-semibold', props.className)}>{children}</h4>;
},
h5({ children, ...props }) {
return <h5 {...props} className={cn('typography-markdown-h4 mt-2.5 mb-1 text-[var(--markdown-heading4,var(--foreground))] font-semibold', props.className)}>{children}</h5>;
},
h6({ children, ...props }) {
return <h6 {...props} className={cn('typography-markdown-h4 mt-2.5 mb-1 text-[var(--markdown-heading4,var(--foreground))] font-semibold', props.className)}>{children}</h6>;
},
p({ children, ...props }) {
return <p {...props} className={cn('typography-markdown-body my-2 text-foreground/90', props.className)}>{children}</p>;
},
thead({ children, ...props }) {
return <thead {...props} className={cn('[&_tr]:border-b [&_tr]:border-border/80', props.className)}>{children}</thead>;
},
tbody({ children, ...props }) {
return <tbody {...props} className={cn('[&_tr:last-child]:border-0', props.className)}>{children}</tbody>;
},
tr({ children, ...props }) {
return <tr {...props} className={cn('border-b border-border/60', props.className)}>{children}</tr>;
},
th({ children, ...props }) {
return <th {...props} className={cn('border-r border-border/60 px-4 py-2.5 text-left align-middle font-semibold text-foreground last:border-r-0', props.className)}>{children}</th>;
},
td({ children, ...props }) {
return <td {...props} className={cn('border-r border-border/60 px-4 py-2.5 align-middle text-foreground/90 last:border-r-0', props.className)}>{children}</td>;
},
ul({ children, ...props }) {
return <ul {...props} className={cn('typography-markdown-body my-2', props.className)}>{children}</ul>;
},
ol({ children, ...props }) {
return <ol {...props} className={cn('typography-markdown-body my-2', props.className)}>{children}</ol>;
},
li({ children, ...props }) {
return <li {...props} className={cn('typography-markdown-body my-0.5 text-foreground/90', props.className)}>{children}</li>;
},
blockquote({ children, ...props }) {
return <blockquote {...props} className={cn('my-3 border-l-2 border-[var(--markdown-blockquote-border,var(--border))] pl-4 typography-markdown-body text-[var(--markdown-blockquote,var(--muted-foreground))]', props.className)}>{children}</blockquote>;
},
pre({ children, ...props }) {
const child = React.Children.only(children) as React.ReactElement<{ className?: string; children?: React.ReactNode }>;
const className = child.props.className;
const language = getCodeLanguage(className);
const code = normalizeCodeBlockText(extractCodeText(child.props.children).replace(/\n$/, ''), language);
if (language === 'mermaid') {
return <MermaidBlock source={code} mode={useUIStore.getState().mermaidRenderingMode} />;
}
return <MarkdownCodeBlock code={code} language={language} syntaxTheme={syntaxTheme} {...props} />;
},
code({ className, children, ...props }) {
return (
<code
{...props}
className={cn('rounded bg-[var(--surface-elevated)] px-1 py-0.5 font-mono text-[0.95em]', className)}
data-markdown="inline-code"
>
{children}
</code>
);
},
a({ href, children, ...props }) {
const targetHref = href ?? '';
const isExternal = isExternalHttpUrl(targetHref);
const isLoopback = onPreviewLoopback ? isLoopbackHttpUrl(targetHref) : false;
return (
<>
<a
{...props}
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
>
{isExternal ? <ExternalLinkFavicon href={targetHref} /> : null}
{children}
</a>
{isLoopback && onPreviewLoopback ? (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onPreviewLoopback(targetHref);
}}
className="ml-1 inline-flex h-5 items-center gap-0.5 rounded border border-[var(--border)] bg-[var(--surface-background)] px-1.5 align-middle text-[11px] leading-none text-[var(--muted-foreground)] transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--foreground)]"
aria-label={previewTitle ?? previewLabel ?? 'Open preview pane'}
title={previewTitle ?? previewLabel ?? 'Open preview pane'}
data-loopback-preview-trigger="true"
>
<Icon name="eye" className="size-3" aria-hidden="true"/>
<span className="font-medium">{previewLabel ?? 'Preview'}</span>
</button>
) : null}
</>
);
},
});
const MarkdownBlockView: React.FC<{
block: MarkdownStreamBlock;
components: Components;
}> = React.memo(({ block, components }) => {
return (
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[[rehypeKatex, { throwOnError: false, errorColor: 'var(--destructive)' }]]} components={components}>
{block.src}
</ReactMarkdown>
);
}, (prev, next) => prev.block === next.block && prev.components === next.components);
MarkdownBlockView.displayName = 'MarkdownBlockView';
interface MarkdownRendererProps {
content: string;
part?: Part;
messageId: string;
isAnimated?: boolean;
skipFadeIn?: boolean;
className?: string;
isStreaming?: boolean;
disableStreamAnimation?: boolean;
variant?: MarkdownVariant;
onShowPopup?: (content: ToolPopupContent) => void;
enableFileReferences?: boolean;
}
const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]';
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_LINK_LIMIT = 200;
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
const getFileReferenceLinkLimit = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
);
type ParsedFileReference = {
path: string;
line?: number;
column?: number;
};
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
const KNOWN_FILE_BASENAMES = new Set([
'dockerfile',
'makefile',
'readme',
'license',
'.env',
'.gitignore',
'.npmrc',
]);
const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|');
const normalizePath = (value: string): string => {
const source = (value || '').trim();
if (!source) {
return '';
}
const withSlashes = source.replace(/\\/g, '/');
const hadUncPrefix = withSlashes.startsWith('//');
let normalized = withSlashes.replace(/\/+/g, '/');
if (hadUncPrefix && !normalized.startsWith('//')) {
normalized = `/${normalized}`;
}
const isUnixRoot = normalized === '/';
const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized);
if (!isUnixRoot && !isWindowsDriveRoot) {
normalized = normalized.replace(/\/+$/, '');
}
return normalized;
};
const isAbsolutePath = (value: string): boolean => {
return value.startsWith('/')
|| WINDOWS_DRIVE_PATH_PATTERN.test(value)
|| WINDOWS_UNC_PATH_PATTERN.test(value)
|| value.startsWith('//');
};
const toAbsolutePath = (basePath: string, targetPath: string): string => {
const normalizedTarget = normalizePath(targetPath);
if (!normalizedTarget) {
return normalizePath(basePath);
}
if (isAbsolutePath(normalizedTarget)) {
return normalizedTarget;
}
const normalizedBase = normalizePath(basePath);
if (!normalizedBase) {
return normalizedTarget;
}
const isWindowsDriveBase = /^[A-Za-z]:/.test(normalizedBase);
const prefix = isWindowsDriveBase ? normalizedBase.slice(0, 2) : '';
const baseRemainder = isWindowsDriveBase ? normalizedBase.slice(2) : normalizedBase;
const stack = baseRemainder.split('/').filter(Boolean);
const parts = normalizedTarget.split('/').filter(Boolean);
for (const part of parts) {
if (part === '.') {
continue;
}
if (part === '..') {
if (stack.length > 0) {
stack.pop();
}
continue;
}
stack.push(part);
}
if (isWindowsDriveBase) {
return `${prefix}/${stack.join('/')}`;
}
return `/${stack.join('/')}`;
};
const trimPathCandidate = (value: string): string => {
let next = (value || '').trim();
if (!next) {
return '';
}
if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) {
next = next.slice(1, -1).trim();
}
next = next.replace(/[.,;!?]+$/g, '');
if (next.endsWith(')') && !next.includes('(')) {
next = next.slice(0, -1);
}
if (next.endsWith(']') && !next.includes('[')) {
next = next.slice(0, -1);
}
return next;
};
const stripTrailingReference = (value: string): string => {
let next = trimPathCandidate(value);
if (!next) {
return '';
}
const semicolonIndex = next.indexOf(';');
if (semicolonIndex >= 0) {
next = next.slice(0, semicolonIndex);
}
next = next.replace(/#.*$/, '');
const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/);
if (extensionSuffixMatch) {
next = extensionSuffixMatch[1] ?? next;
}
const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0
? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i'))
: null;
if (basenameSuffixMatch) {
next = basenameSuffixMatch[1] ?? next;
}
return trimPathCandidate(next);
};
const parseFileReference = (value: string): ParsedFileReference | null => {
const trimmed = trimPathCandidate(value);
if (!trimmed) {
return null;
}
const semicolonIndex = trimmed.indexOf(';');
const withoutSemicolonSuffix = semicolonIndex >= 0
? trimPathCandidate(trimmed.slice(0, semicolonIndex))
: trimmed;
if (!withoutSemicolonSuffix) {
return null;
}
const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i);
if (hashMatch) {
const path = stripTrailingReference(hashMatch[1] ?? '');
const line = Number.parseInt(hashMatch[2] ?? '', 10);
const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined;
if (!path || !Number.isFinite(line)) {
return null;
}
return {
path,
line,
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
};
}
const colonMatch = withoutSemicolonSuffix.match(/^(.*):(\d+)(?::(\d+))?$/);
if (colonMatch) {
const path = stripTrailingReference(colonMatch[1] ?? '');
const line = Number.parseInt(colonMatch[2] ?? '', 10);
const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined;
if (!path || !Number.isFinite(line)) {
return null;
}
return {
path,
line,
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
};
}
const pathOnly = stripTrailingReference(withoutSemicolonSuffix);
if (!pathOnly) {
return null;
}
return { path: pathOnly };
};
const hasFileExtension = (path: string): boolean => {
const base = path.split('/').filter(Boolean).pop() ?? '';
if (!base || base.endsWith('.')) {
return false;
}
return /\.[A-Za-z0-9_-]{1,16}$/.test(base);
};
const isLikelyFilePathValue = (path: string): boolean => {
if (!path || path.startsWith('--') || path.includes('://')) {
return false;
}
if (/[<>]/.test(path) || /\s{2,}/.test(path)) {
return false;
}
const normalized = normalizePath(path);
const baseName = normalized.split('/').filter(Boolean).pop() ?? normalized;
if (!baseName || baseName === '.' || baseName === '..') {
return false;
}
const base = baseName.toLowerCase();
if (KNOWN_FILE_BASENAMES.has(base) || (base.startsWith('.') && base.length > 1)) {
return true;
}
return hasFileExtension(normalized);
};
const isLikelyFilePath = (value: string): boolean => {
const parsed = parseFileReference(value);
if (!parsed) {
return false;
}
return isLikelyFilePathValue(parsed.path);
};
const extractPathCandidateFromElement = (element: HTMLElement): string => {
if (element.tagName.toLowerCase() === 'a') {
const href = element.getAttribute('href')?.trim();
if (href && isLikelyFilePath(href)) {
return href;
}
}
return (element.textContent || '').trim();
};
const getResolvedReference = (rawValue: string, effectiveDirectory: string): (ParsedFileReference & { resolvedPath: string }) | null => {
const parsed = parseFileReference(rawValue);
if (!parsed || !isLikelyFilePathValue(parsed.path)) {
return null;
}
const resolvedPath = isAbsolutePath(parsed.path)
? normalizePath(parsed.path)
: toAbsolutePath(effectiveDirectory, parsed.path);
if (!resolvedPath) {
return null;
}
return {
...parsed,
resolvedPath,
};
};
const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const normalizedPath = normalizePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void fetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}`, {
method: 'GET',
cache: 'no-store',
})
.then((response) => resolve(response.ok))
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
return request;
};
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
const normalizedDirectory = normalizePath(effectiveDirectory);
if (normalizedDirectory) {
return normalizedDirectory;
}
const normalizedPath = normalizePath(resolvedPath);
const parent = normalizedPath.replace(/\/[^/]*$/, '');
return parent || normalizedPath;
};
const useFileReferenceInteractions = ({
containerRef,
effectiveDirectory,
editor,
preferRuntimeEditor,
enabled,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
effectiveDirectory: string;
editor?: EditorAPI;
preferRuntimeEditor?: boolean;
enabled: boolean;
}) => {
const annotationDebounceRef = React.useRef<number | null>(null);
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
let cancelled = false;
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
const clearFileLinkAttributes = (candidate: HTMLElement) => {
candidate.removeAttribute('data-openchamber-file-link');
candidate.removeAttribute('data-openchamber-file-ref');
candidate.removeAttribute('data-openchamber-file-path');
if (candidate.getAttribute('title') === 'Open file') {
candidate.removeAttribute('title');
}
if (candidate.tagName.toLowerCase() !== 'a') {
candidate.removeAttribute('role');
candidate.removeAttribute('tabindex');
}
};
const clearAnnotatedFileLinks = () => {
const annotated = container.querySelectorAll<HTMLElement>(FILE_LINK_SELECTOR);
for (const candidate of Array.from(annotated)) {
clearFileLinkAttributes(candidate);
}
};
if (!enabled) {
clearAnnotatedFileLinks();
return;
}
const annotateFileLinks = () => {
const candidates = container.querySelectorAll<HTMLElement>('[data-markdown="inline-code"], a');
let linkedCount = 0;
for (const candidate of Array.from(candidates)) {
const rawCandidate = extractPathCandidateFromElement(candidate);
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
clearFileLinkAttributes(candidate);
if (!resolved) {
continue;
}
if (linkedCount >= fileReferenceLinkLimit) {
continue;
}
linkedCount += 1;
void fileReferenceExists(resolved.resolvedPath).then((exists) => {
if (cancelled || !exists || !container.contains(candidate)) {
return;
}
const latestRawCandidate = extractPathCandidateFromElement(candidate);
const latestResolved = getResolvedReference(latestRawCandidate, effectiveDirectory);
if (!latestResolved || latestResolved.resolvedPath !== resolved.resolvedPath) {
return;
}
candidate.setAttribute('data-openchamber-file-link', 'true');
candidate.setAttribute('data-openchamber-file-ref', latestRawCandidate);
candidate.setAttribute('data-openchamber-file-path', latestResolved.resolvedPath);
candidate.setAttribute('title', 'Open file');
if (candidate.tagName.toLowerCase() !== 'a') {
candidate.setAttribute('role', 'button');
candidate.setAttribute('tabindex', '0');
}
});
}
};
const openFileReference = (sourceElement: HTMLElement) => {
const raw = sourceElement.getAttribute('data-openchamber-file-ref') || extractPathCandidateFromElement(sourceElement);
const resolved = getResolvedReference(raw, effectiveDirectory);
if (!resolved) {
return;
}
const contextDirectory = getContextDirectory(effectiveDirectory, resolved.resolvedPath);
if (preferRuntimeEditor && editor) {
void editor.openFile(
resolved.resolvedPath,
Number.isFinite(resolved.line ?? Number.NaN)
? Math.max(1, Math.trunc(resolved.line as number))
: undefined,
Number.isFinite(resolved.column ?? Number.NaN)
? Math.max(1, Math.trunc(resolved.column as number))
: undefined,
);
return;
}
const uiStore = useUIStore.getState();
if (Number.isFinite(resolved.line ?? Number.NaN)) {
uiStore.openContextFileAtLine(
contextDirectory,
resolved.resolvedPath,
Math.max(1, Math.trunc(resolved.line as number)),
Number.isFinite(resolved.column ?? Number.NaN)
? Math.max(1, Math.trunc(resolved.column as number))
: 1,
);
} else {
uiStore.openContextFile(contextDirectory, resolved.resolvedPath);
}
};
const handleClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const fileRefElement = target.closest(FILE_LINK_SELECTOR);
if (!(fileRefElement instanceof HTMLElement)) {
return;
}
event.preventDefault();
event.stopPropagation();
openFileReference(fileRefElement);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
const target = event.target;
if (!(target instanceof HTMLElement) || target.getAttribute('data-openchamber-file-link') !== 'true') {
return;
}
event.preventDefault();
event.stopPropagation();
openFileReference(target);
};
annotateFileLinks();
const observer = new MutationObserver(() => {
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(annotationDebounceRef.current);
}
if (typeof window === 'undefined') {
annotateFileLinks();
return;
}
annotationDebounceRef.current = window.setTimeout(() => {
annotationDebounceRef.current = null;
annotateFileLinks();
}, 120);
});
observer.observe(container, {
childList: true,
subtree: true,
});
container.addEventListener('click', handleClick);
container.addEventListener('keydown', handleKeyDown);
return () => {
cancelled = true;
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(annotationDebounceRef.current);
}
annotationDebounceRef.current = null;
observer.disconnect();
container.removeEventListener('click', handleClick);
container.removeEventListener('keydown', handleKeyDown);
};
}, [containerRef, editor, effectiveDirectory, preferRuntimeEditor, enabled]);
};
const useMermaidInlineInteractions = ({
containerRef,
mermaidBlocks,
onShowPopup,
allowWheelZoom,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
mermaidBlocks: string[];
onShowPopup?: (content: ToolPopupContent) => void;
allowWheelZoom?: boolean;
}) => {
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const handleMermaidClick = (event: MouseEvent) => {
if (!onShowPopup) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
if (target.closest('button, a, [role="button"]')) {
return;
}
const block = target.closest(MERMAID_BLOCK_SELECTOR);
if (!block) {
return;
}
const renderedBlocks = Array.from(container.querySelectorAll(MERMAID_BLOCK_SELECTOR));
const blockIndex = renderedBlocks.indexOf(block);
if (blockIndex < 0) {
return;
}
const source = mermaidBlocks[blockIndex];
if (!source || source.trim().length === 0) {
return;
}
const filename = `Diagram ${blockIndex + 1}`;
onShowPopup({
open: true,
title: filename,
content: '',
metadata: {
tool: 'mermaid-preview',
filename,
},
mermaid: {
url: `data:text/plain;charset=utf-8,${encodeURIComponent(source)}`,
source,
filename,
},
});
};
const handleInlineWheel = (event: WheelEvent) => {
if (allowWheelZoom) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const block = target.closest(MERMAID_BLOCK_SELECTOR);
if (!block) {
return;
}
// Keep regular page scroll while preventing Streamdown inline wheel-zoom handlers.
event.stopPropagation();
};
container.addEventListener('click', handleMermaidClick);
container.addEventListener('wheel', handleInlineWheel, { capture: true, passive: true });
return () => {
container.removeEventListener('click', handleMermaidClick);
container.removeEventListener('wheel', handleInlineWheel, true);
};
}, [allowWheelZoom, containerRef, mermaidBlocks, onShowPopup]);
};
const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
content,
part,
messageId,
isAnimated = true,
skipFadeIn = false,
className,
isStreaming = false,
disableStreamAnimation = false,
variant = 'assistant',
onShowPopup,
enableFileReferences = true,
}) => {
const currentTheme = useCurrentMermaidTheme();
const { editor, runtime } = useRuntimeAPIs();
const containerRef = React.useRef<HTMLDivElement>(null);
const effectiveDirectory = useEffectiveDirectory() ?? '';
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
useMermaidInlineInteractions({ containerRef, mermaidBlocks, onShowPopup });
useFileReferenceInteractions({
containerRef,
effectiveDirectory,
editor,
preferRuntimeEditor: runtime.isVSCode,
enabled: enableFileReferences && !isStreaming,
});
useExternalLinkInteractions({ containerRef });
const openContextPreview = useUIStore((state) => state.openContextPreview);
const { t } = useI18n();
const handlePreviewLoopback = React.useCallback((url: string) => {
if (!effectiveDirectory) return;
openContextPreview(effectiveDirectory, url);
}, [effectiveDirectory, openContextPreview]);
const previewLabel = t('terminalView.preview.open');
const previewTitle = t('terminalView.preview.openTitle');
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const markdownComponents = React.useMemo(
() => buildMarkdownComponents({
syntaxTheme,
onPreviewLoopback: effectiveDirectory ? handlePreviewLoopback : undefined,
previewLabel,
previewTitle,
}),
[syntaxTheme, effectiveDirectory, handlePreviewLoopback, previewLabel, previewTitle],
);
const componentKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
const markdownBlocks = useStableMarkdownBlocks(content, isStreaming && !disableStreamAnimation, componentKey);
const markdownClassName = variant === 'tool'
? 'markdown-content markdown-tool'
: variant === 'reasoning'
? 'markdown-content markdown-reasoning'
: 'markdown-content leading-relaxed';
const markdownContent = (
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
<div className={markdownClassName}>
{markdownBlocks.map((block) => (
<MarkdownBlockView key={block.key} block={block} components={markdownComponents} />
))}
</div>
</div>
);
if (isAnimated) {
return (
<FadeInOnReveal key={componentKey} skipAnimation={skipFadeIn}>
{markdownContent}
</FadeInOnReveal>
);
}
return markdownContent;
};
export const MarkdownRenderer = React.memo(MarkdownRendererImpl, (prev, next) => {
return prev.content === next.content
&& prev.isStreaming === next.isStreaming
&& prev.disableStreamAnimation === next.disableStreamAnimation
&& prev.variant === next.variant
&& prev.isAnimated === next.isAnimated
&& prev.skipFadeIn === next.skipFadeIn
&& prev.className === next.className
&& prev.messageId === next.messageId
&& prev.onShowPopup === next.onShowPopup
&& prev.part?.id === next.part?.id;
});
const SimpleMarkdownRendererImpl: React.FC<{
content: string;
className?: string;
variant?: MarkdownVariant;
disableLinkSafety?: boolean;
stripFrontmatter?: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
mermaidControls?: MermaidControlOptions;
allowMermaidWheelZoom?: boolean;
enableFileReferences?: boolean;
}> = ({
content,
className,
variant = 'assistant',
disableLinkSafety,
stripFrontmatter = false,
onShowPopup,
allowMermaidWheelZoom = false,
enableFileReferences = true,
}) => {
const { editor, runtime } = useRuntimeAPIs();
const renderedContent = React.useMemo(
() => (stripFrontmatter ? stripLeadingFrontmatter(content) : content),
[content, stripFrontmatter],
);
const currentTheme = useCurrentMermaidTheme();
const containerRef = React.useRef<HTMLDivElement>(null);
const effectiveDirectory = useEffectiveDirectory() ?? '';
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]);
useMermaidInlineInteractions({
containerRef,
mermaidBlocks,
onShowPopup,
allowWheelZoom: allowMermaidWheelZoom,
});
useFileReferenceInteractions({
containerRef,
effectiveDirectory,
editor,
preferRuntimeEditor: runtime.isVSCode,
enabled: enableFileReferences,
});
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
const markdownComponents = React.useMemo(() => buildMarkdownComponents({ syntaxTheme }), [syntaxTheme]);
const markdownBlocks = useStableMarkdownBlocks(renderedContent, false, `simple:${variant}`);
const markdownClassName = variant === 'tool'
? 'markdown-content markdown-tool'
: variant === 'reasoning'
? 'markdown-content markdown-reasoning'
: 'markdown-content leading-relaxed';
return (
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
<div className={markdownClassName}>
{markdownBlocks.map((block) => (
<MarkdownBlockView key={block.key} block={block} components={markdownComponents} />
))}
</div>
</div>
);
};
export const SimpleMarkdownRenderer = React.memo(SimpleMarkdownRendererImpl, (prev, next) => {
return prev.content === next.content
&& prev.variant === next.variant
&& prev.className === next.className
&& prev.disableLinkSafety === next.disableLinkSafety
&& prev.stripFrontmatter === next.stripFrontmatter
&& prev.onShowPopup === next.onShowPopup
&& prev.allowMermaidWheelZoom === next.allowMermaidWheelZoom
&& prev.enableFileReferences === next.enableFileReferences;
});