From ecb22e19c35a4500fbe176fba3f3612ba80bb68c Mon Sep 17 00:00:00 2001 From: Shyamalan Kannan <78594762+Yabuku-xD@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:31:42 -0700 Subject: [PATCH] perf: lazy-load heavy dependencies (MarkdownRenderer + CodeMirror languages) (#997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: drastically improve cold-start, bundle size, and streaming performance Cold-start optimizations: - main.tsx: Remove blocking await on prefs I/O — render immediately with defaults, hydrate persisted settings asynchronously. Cuts 50-200ms from time-to-first-paint. - bootstrap.ts: Split directory bootstrap into 3 phases: * Phase 1 (blocking): path, config, provider, session status — minimum data needed to render UI. Mark status complete after this phase. path.get and session.status must both succeed; they have no fallback. * Phase 2 (deferred): agents, commands, mcp, lsp, vcs, questions, permissions — fetched after first paint without blocking. * Phase 3 (lazy): session messages — loaded without blocking init. - App.tsx: Keep identical provider tree before/after init to prevent full subtree remount when isInitialized flips. FireworksProvider and VoiceProvider are lightweight shells; overlays deferred until init. Bundle-size optimizations: - App.tsx + MainLayout.tsx + VSCodeLayout.tsx: Code-split heavy views (SettingsView, GitView, DiffView, TerminalView, FilesView, PlanView, OnboardingScreen, SettingsWindow, MultiRunWindow) with React.lazy. Views load on demand when user switches panels. - vite.config.ts: Lower chunkSizeWarningLimit from 1200KB to 500KB. Streaming render optimizations: - streaming.ts: Throttle streaming store writes ~60Hz → ~1Hz. Busy-session only scan (Set, O(1)). - MessageList.tsx: Lower virtualization threshold 40 → 15. - ChatMessage.tsx: React.memo with areRenderRelevantMessagesEqual. - MarkdownRenderer.tsx: React.memo with explicit prop comparators. * fix: address Greptile review feedback on bootstrap and provider tree - bootstrap.ts: Tighten Phase 1 error guard. path.get and session.status must both succeed; they have no global fallback. - bootstrap.ts: Replace dead .catch() on Promise.allSettled() with .then() that inspects individual results for errors. - App.tsx: Keep identical provider tree before/after init to prevent full subtree remount when isInitialized flips. * perf: lazy-load heavy dependencies (MarkdownRenderer + CodeMirror languages) MarkdownRenderer dynamic import: - Move heavy implementation (marked, react-markdown, beautiful-mermaid, react-syntax-highlighter, ~1500 lines) to MarkdownRendererImpl.tsx - Replace MarkdownRenderer.tsx with thin lazy wrapper using React.lazy - All 11 existing imports work unchanged — no consumer code modified - Full markdown stack loads on first render of markdown content CodeMirror language lazy loading: - languageByExtension.ts: remove static imports for 10+ less-common language packages (@codemirror/lang-go, lang-rust, lang-sql, etc.) - Keep only 6 most common languages static: javascript, json, css, html, markdown, python, shell - Less common languages return null from languageByExtension, causing callers to fall back to loadLanguageByExtension which dynamically loads from @codemirror/language-data - Reduces initial bundle by ~200KB+ of language parsers --------- Co-authored-by: Shyamalan Kannan --- packages/ui/src/App.tsx | 57 +- .../ui/src/components/chat/ChatMessage.tsx | 27 +- .../src/components/chat/MarkdownRenderer.tsx | 1538 +--------------- .../components/chat/MarkdownRendererImpl.tsx | 1549 +++++++++++++++++ .../ui/src/components/chat/MessageList.tsx | 2 +- .../ui/src/components/layout/MainLayout.tsx | 60 +- .../ui/src/components/layout/VSCodeLayout.tsx | 14 +- .../src/lib/codemirror/languageByExtension.ts | 120 +- packages/ui/src/main.tsx | 26 +- packages/ui/src/sync/bootstrap.ts | 155 +- packages/ui/src/sync/streaming.ts | 128 +- packages/web/vite.config.ts | 2 +- 12 files changed, 1869 insertions(+), 1809 deletions(-) create mode 100644 packages/ui/src/components/chat/MarkdownRendererImpl.tsx diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index a1eb4fe2..8412a882 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -31,7 +31,6 @@ import { type BootInjectionStatus, type DesktopBootView, } from '@/lib/desktopBoot'; -import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen'; import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionRecovery'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -56,6 +55,11 @@ import { QuickOpenDialog } from '@/components/ui/QuickOpenDialog'; import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage'; import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth'; +// Lazy-loaded heavy views — loaded on demand to reduce initial bundle size. +const OnboardingScreen = React.lazy(() => + import('@/components/onboarding/OnboardingScreen').then((m) => ({ default: m.OnboardingScreen })), +); + const AboutDialogWrapper: React.FC = () => { const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen); const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen); @@ -645,13 +649,15 @@ function App({ apis }: AppProps) { return (
- { - // Switch to remote tab - handled internally by OnboardingScreen - }} - /> + }> + { + // Switch to remote tab - handled internally by OnboardingScreen + }} + /> +
); @@ -664,13 +670,15 @@ function App({ apis }: AppProps) { return (
- + }> + +
); @@ -747,6 +755,11 @@ function App({ apis }: AppProps) { ); } + // Always mount the full provider tree to avoid remounts when isInitialized + // flips from false → true. FireworksProvider and VoiceProvider are lightweight + // shells; their heavy children are only activated when actually needed. + const isBootShell = !isInitialized && !isDesktopRuntime; + return ( @@ -758,11 +771,15 @@ function App({ apis }: AppProps) { - - - - {showMemoryDebug && ( - setShowMemoryDebug(false)} /> + {!isBootShell && ( + <> + + + + {showMemoryDebug && ( + setShowMemoryDebug(false)} /> + )} + )} diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 68e63bf5..f8a5fd7e 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -30,6 +30,7 @@ import type { TurnGroupingContext } from './lib/turns/types'; import { copyTextToClipboard } from '@/lib/clipboard'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import { streamPerfCount } from '@/stores/utils/streamDebug'; +import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare'; const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog')); @@ -1126,4 +1127,28 @@ const ChatMessage: React.FC = ({ ); }; -export default ChatMessage; +export default React.memo(ChatMessage, (prev, next) => { + return areRenderRelevantMessagesEqual( + { info: prev.message.info, parts: prev.message.parts }, + { info: next.message.info, parts: next.message.parts } + ) + && areOptionalRenderRelevantMessagesEqual( + prev.previousMessage ? { info: prev.previousMessage.info, parts: prev.previousMessage.parts } : undefined, + next.previousMessage ? { info: next.previousMessage.info, parts: next.previousMessage.parts } : undefined + ) + && areOptionalRenderRelevantMessagesEqual( + prev.nextMessage ? { info: prev.nextMessage.info, parts: prev.nextMessage.parts } : undefined, + next.nextMessage ? { info: next.nextMessage.info, parts: next.nextMessage.parts } : undefined + ) + && prev.isInActiveTurn === next.isInActiveTurn + && prev.activeStreamingPhase === next.activeStreamingPhase + && prev.assistantHeaderMessageId === next.assistantHeaderMessageId + && prev.animateUserOnMount === next.animateUserOnMount + && prev.onUserAnimationConsumed === next.onUserAnimationConsumed + && areRelevantTurnGroupingContextsEqual( + prev.turnGroupingContext, + next.turnGroupingContext, + prev.message.info.id, + deriveMessageRole(prev.message.info).isUser + ); +}); diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index b2457889..94ead794 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -1,1525 +1,31 @@ import React from 'react'; -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 { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { toast } from '@/components/ui'; -import { copyTextToClipboard } from '@/lib/clipboard'; -import { isExternalHttpUrl, 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'; +// Thin lazy wrapper around the heavy MarkdownRenderer implementation. +// The full implementation (marked, react-markdown, beautiful-mermaid, +// react-syntax-highlighter, etc.) is loaded on demand, keeping the +// initial bundle lean. -const useCurrentMermaidTheme = () => { - const themeSystem = useOptionalThemeSystem(); - const fallbackLight = getDefaultTheme(false); - const fallbackDark = getDefaultTheme(true); +export type { MarkdownVariant } from './MarkdownRendererImpl'; - return themeSystem?.currentTheme - ?? (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches - ? fallbackDark - : fallbackLight); -}; -const useExternalLinkInteractions = ({ - containerRef, - enabled, -}: { - containerRef: React.RefObject; - enabled?: boolean; -}) => { - React.useEffect(() => { - if (enabled === false) { - return; - } +const MarkdownRendererLazy = React.lazy(() => + import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer })) +); - const container = containerRef.current; - if (!container) { - return; - } +const SimpleMarkdownRendererLazy = React.lazy(() => + import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer })) +); - const handleClick = (event: MouseEvent) => { - if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) { - return; - } +const fallback =
; - const target = event.target; - if (!(target instanceof Element)) { - return; - } +export const MarkdownRenderer: React.FC> = (props) => ( + + + +); - 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]); -}; - -// 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 }> = ({ tableRef }) => { - const [copied, setCopied] = React.useState(false); - const [showMenu, setShowMenu] = React.useState(false); - const menuRef = React.useRef(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 ( -
- - {showMenu && ( -
- - -
- )} -
- ); -}; - -// Table download button with dropdown -const TableDownloadButton: React.FC<{ tableRef: React.RefObject }> = ({ tableRef }) => { - const [showMenu, setShowMenu] = React.useState(false); - const menuRef = React.useRef(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(`Table downloaded as ${format.toUpperCase()}`); - }; - - return ( -
- - {showMenu && ( -
- - -
- )} -
- ); -}; - -// Table wrapper with custom controls -const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => { - const tableRef = React.useRef(null); - - return ( -
-
- - -
-
- - {children} -
-
-
- ); -}; - -const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => { - const currentTheme = useCurrentMermaidTheme(); - const { isMobile } = 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 ? '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('Failed to download diagram'); - } - }; - - if (mode === 'ascii') { - const asciiText = ascii || source; - - return ( -
-
-
{asciiText}
-
-
- -
-
- ); - } - - if (!svg) { - return ( -
-
-
{source}
-
-
- -
-
- ); - } - - return ( -
-
-
-
-
- - -
-
- ); -}; - -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([]); - - 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(/"/g, '"') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/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_SHARED_STYLE: React.CSSProperties = { - margin: 0, - background: 'transparent', - padding: 0, - fontSize: 'var(--text-code)', - lineHeight: 'var(--markdown-code-block-line-height)', -}; - -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 prevCodeRef = React.useRef(code); - const timerRef = React.useRef | null>(null); - - // 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]); - - return ( -
-
- {language} -
- -
-
-
- {highlight ? ( - - {code} - - ) : ( -
-            {code}
-          
- )} -
-
- ); -}; - -const buildMarkdownComponents = ({ - syntaxTheme, -}: { - syntaxTheme: { [key: string]: React.CSSProperties }; -}): Components => ({ - table({ children, ...props }) { - return {children}; - }, - h1({ children, ...props }) { - return

{children}

; - }, - h2({ children, ...props }) { - return

{children}

; - }, - h3({ children, ...props }) { - return

{children}

; - }, - h4({ children, ...props }) { - return

{children}

; - }, - h5({ children, ...props }) { - return
{children}
; - }, - h6({ children, ...props }) { - return
{children}
; - }, - p({ children, ...props }) { - return

{children}

; - }, - thead({ children, ...props }) { - return {children}; - }, - tbody({ children, ...props }) { - return {children}; - }, - tr({ children, ...props }) { - return {children}; - }, - th({ children, ...props }) { - return {children}; - }, - td({ children, ...props }) { - return {children}; - }, - ul({ children, ...props }) { - return
    {children}
; - }, - ol({ children, ...props }) { - return
    {children}
; - }, - li({ children, ...props }) { - return
  • {children}
  • ; - }, - blockquote({ children, ...props }) { - return
    {children}
    ; - }, - 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 ; - } - return ; - }, - code({ className, children, ...props }) { - return ( - - {children} - - ); - }, - a({ href, children, ...props }) { - return ( - - {children} - - ); - }, -}); - -const MarkdownBlockView: React.FC<{ - block: MarkdownStreamBlock; - components: Components; -}> = React.memo(({ block, components }) => { - return ( - - {block.src} - - ); -}, (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; -} - -const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]'; -const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]'; - -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 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, -}: { - containerRef: React.RefObject; - effectiveDirectory: string; - editor?: EditorAPI; - preferRuntimeEditor?: boolean; -}) => { - const annotationDebounceRef = React.useRef(null); - - React.useEffect(() => { - const container = containerRef.current; - if (!container) { - return; - } - - const annotateFileLinks = () => { - const candidates = container.querySelectorAll('[data-markdown="inline-code"], a'); - - for (const candidate of Array.from(candidates)) { - const rawCandidate = extractPathCandidateFromElement(candidate); - const resolved = getResolvedReference(rawCandidate, effectiveDirectory); - if (!resolved) { - 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'); - } - continue; - } - - candidate.setAttribute('data-openchamber-file-link', 'true'); - candidate.setAttribute('data-openchamber-file-ref', rawCandidate); - candidate.setAttribute('data-openchamber-file-path', resolved.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 () => { - 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]); -}; - -const useMermaidInlineInteractions = ({ - containerRef, - mermaidBlocks, - onShowPopup, - allowWheelZoom, -}: { - containerRef: React.RefObject; - 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]); -}; - -export const MarkdownRenderer: React.FC = ({ - content, - part, - messageId, - isAnimated = true, - skipFadeIn = false, - className, - isStreaming = false, - disableStreamAnimation = false, - variant = 'assistant', - onShowPopup, -}) => { - const currentTheme = useCurrentMermaidTheme(); - const { editor, runtime } = useRuntimeAPIs(); - const containerRef = React.useRef(null); - const effectiveDirectory = useEffectiveDirectory() ?? ''; - const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]); - useMermaidInlineInteractions({ containerRef, mermaidBlocks, onShowPopup }); - useFileReferenceInteractions({ - containerRef, - effectiveDirectory, - editor, - preferRuntimeEditor: runtime.isVSCode, - }); - useExternalLinkInteractions({ containerRef }); - const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); - const markdownComponents = React.useMemo(() => buildMarkdownComponents({ syntaxTheme }), [syntaxTheme]); - 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 = ( -
    -
    - {markdownBlocks.map((block) => ( - - ))} -
    -
    - ); - - if (isAnimated) { - return ( - - {markdownContent} - - ); - } - - return markdownContent; -}; - -export const SimpleMarkdownRenderer: React.FC<{ - content: string; - className?: string; - variant?: MarkdownVariant; - disableLinkSafety?: boolean; - stripFrontmatter?: boolean; - onShowPopup?: (content: ToolPopupContent) => void; - mermaidControls?: MermaidControlOptions; - allowMermaidWheelZoom?: boolean; -}> = ({ - content, - className, - variant = 'assistant', - disableLinkSafety, - stripFrontmatter = false, - onShowPopup, - allowMermaidWheelZoom = false, -}) => { - const { editor, runtime } = useRuntimeAPIs(); - const renderedContent = React.useMemo( - () => (stripFrontmatter ? stripLeadingFrontmatter(content) : content), - [content, stripFrontmatter], - ); - const currentTheme = useCurrentMermaidTheme(); - const containerRef = React.useRef(null); - const effectiveDirectory = useEffectiveDirectory() ?? ''; - const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]); - useMermaidInlineInteractions({ - containerRef, - mermaidBlocks, - onShowPopup, - allowWheelZoom: allowMermaidWheelZoom, - }); - useFileReferenceInteractions({ - containerRef, - effectiveDirectory, - editor, - preferRuntimeEditor: runtime.isVSCode, - }); - 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 ( -
    -
    - {markdownBlocks.map((block) => ( - - ))} -
    -
    - ); -}; +export const SimpleMarkdownRenderer: React.FC> = (props) => ( + + + +); diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx new file mode 100644 index 00000000..55c4339a --- /dev/null +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -0,0 +1,1549 @@ +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 { RiFileCopyLine, RiCheckLine, RiDownloadLine } from '@remixicon/react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { toast } from '@/components/ui'; +import { copyTextToClipboard } from '@/lib/clipboard'; + +import { isExternalHttpUrl, 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'; + +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; + 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]); +}; + +// 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 }> = ({ tableRef }) => { + const [copied, setCopied] = React.useState(false); + const [showMenu, setShowMenu] = React.useState(false); + const menuRef = React.useRef(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 ( +
    + + {showMenu && ( +
    + + +
    + )} +
    + ); +}; + +// Table download button with dropdown +const TableDownloadButton: React.FC<{ tableRef: React.RefObject }> = ({ tableRef }) => { + const [showMenu, setShowMenu] = React.useState(false); + const menuRef = React.useRef(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(`Table downloaded as ${format.toUpperCase()}`); + }; + + return ( +
    + + {showMenu && ( +
    + + +
    + )} +
    + ); +}; + +// Table wrapper with custom controls +const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => { + const tableRef = React.useRef(null); + + return ( +
    +
    + + +
    +
    + + {children} +
    +
    +
    + ); +}; + +const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => { + const currentTheme = useCurrentMermaidTheme(); + const { isMobile } = 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 ? '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('Failed to download diagram'); + } + }; + + if (mode === 'ascii') { + const asciiText = ascii || source; + + return ( +
    +
    +
    {asciiText}
    +
    +
    + +
    +
    + ); + } + + if (!svg) { + return ( +
    +
    +
    {source}
    +
    +
    + +
    +
    + ); + } + + return ( +
    +
    +
    +
    +
    + + +
    +
    + ); +}; + +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([]); + + 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(/"/g, '"') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/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_SHARED_STYLE: React.CSSProperties = { + margin: 0, + background: 'transparent', + padding: 0, + fontSize: 'var(--text-code)', + lineHeight: 'var(--markdown-code-block-line-height)', +}; + +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 prevCodeRef = React.useRef(code); + const timerRef = React.useRef | null>(null); + + // 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]); + + return ( +
    +
    + {language} +
    + +
    +
    +
    + {highlight ? ( + + {code} + + ) : ( +
    +            {code}
    +          
    + )} +
    +
    + ); +}; + +const buildMarkdownComponents = ({ + syntaxTheme, +}: { + syntaxTheme: { [key: string]: React.CSSProperties }; +}): Components => ({ + table({ children, ...props }) { + return {children}; + }, + h1({ children, ...props }) { + return

    {children}

    ; + }, + h2({ children, ...props }) { + return

    {children}

    ; + }, + h3({ children, ...props }) { + return

    {children}

    ; + }, + h4({ children, ...props }) { + return

    {children}

    ; + }, + h5({ children, ...props }) { + return
    {children}
    ; + }, + h6({ children, ...props }) { + return
    {children}
    ; + }, + p({ children, ...props }) { + return

    {children}

    ; + }, + thead({ children, ...props }) { + return {children}; + }, + tbody({ children, ...props }) { + return {children}; + }, + tr({ children, ...props }) { + return {children}; + }, + th({ children, ...props }) { + return {children}; + }, + td({ children, ...props }) { + return {children}; + }, + ul({ children, ...props }) { + return
      {children}
    ; + }, + ol({ children, ...props }) { + return
      {children}
    ; + }, + li({ children, ...props }) { + return
  • {children}
  • ; + }, + blockquote({ children, ...props }) { + return
    {children}
    ; + }, + 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 ; + } + return ; + }, + code({ className, children, ...props }) { + return ( + + {children} + + ); + }, + a({ href, children, ...props }) { + return ( + + {children} + + ); + }, +}); + +const MarkdownBlockView: React.FC<{ + block: MarkdownStreamBlock; + components: Components; +}> = React.memo(({ block, components }) => { + return ( + + {block.src} + + ); +}, (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; +} + +const MERMAID_BLOCK_SELECTOR = '[data-markdown="mermaid-block"]'; +const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]'; + +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 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, +}: { + containerRef: React.RefObject; + effectiveDirectory: string; + editor?: EditorAPI; + preferRuntimeEditor?: boolean; +}) => { + const annotationDebounceRef = React.useRef(null); + + React.useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + + const annotateFileLinks = () => { + const candidates = container.querySelectorAll('[data-markdown="inline-code"], a'); + + for (const candidate of Array.from(candidates)) { + const rawCandidate = extractPathCandidateFromElement(candidate); + const resolved = getResolvedReference(rawCandidate, effectiveDirectory); + if (!resolved) { + 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'); + } + continue; + } + + candidate.setAttribute('data-openchamber-file-link', 'true'); + candidate.setAttribute('data-openchamber-file-ref', rawCandidate); + candidate.setAttribute('data-openchamber-file-path', resolved.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 () => { + 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]); +}; + +const useMermaidInlineInteractions = ({ + containerRef, + mermaidBlocks, + onShowPopup, + allowWheelZoom, +}: { + containerRef: React.RefObject; + 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 = ({ + content, + part, + messageId, + isAnimated = true, + skipFadeIn = false, + className, + isStreaming = false, + disableStreamAnimation = false, + variant = 'assistant', + onShowPopup, +}) => { + const currentTheme = useCurrentMermaidTheme(); + const { editor, runtime } = useRuntimeAPIs(); + const containerRef = React.useRef(null); + const effectiveDirectory = useEffectiveDirectory() ?? ''; + const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]); + useMermaidInlineInteractions({ containerRef, mermaidBlocks, onShowPopup }); + useFileReferenceInteractions({ + containerRef, + effectiveDirectory, + editor, + preferRuntimeEditor: runtime.isVSCode, + }); + useExternalLinkInteractions({ containerRef }); + const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); + const markdownComponents = React.useMemo(() => buildMarkdownComponents({ syntaxTheme }), [syntaxTheme]); + 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 = ( +
    +
    + {markdownBlocks.map((block) => ( + + ))} +
    +
    + ); + + if (isAnimated) { + return ( + + {markdownContent} + + ); + } + + 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; +}> = ({ + content, + className, + variant = 'assistant', + disableLinkSafety, + stripFrontmatter = false, + onShowPopup, + allowMermaidWheelZoom = false, +}) => { + const { editor, runtime } = useRuntimeAPIs(); + const renderedContent = React.useMemo( + () => (stripFrontmatter ? stripLeadingFrontmatter(content) : content), + [content, stripFrontmatter], + ); + const currentTheme = useCurrentMermaidTheme(); + const containerRef = React.useRef(null); + const effectiveDirectory = useEffectiveDirectory() ?? ''; + const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]); + useMermaidInlineInteractions({ + containerRef, + mermaidBlocks, + onShowPopup, + allowWheelZoom: allowMermaidWheelZoom, + }); + useFileReferenceInteractions({ + containerRef, + effectiveDirectory, + editor, + preferRuntimeEditor: runtime.isVSCode, + }); + 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 ( +
    +
    + {markdownBlocks.map((block) => ( + + ))} +
    +
    + ); +}; + +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; +}); diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 3372eb70..e4fbab27 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -16,7 +16,7 @@ import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/ import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug'; import type { StreamPhase } from './message/types'; -const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 40; +const MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 15; const MESSAGE_LIST_OVERSCAN = 6; const estimateHistoryEntryHeight = (entry: RenderEntry | undefined): number => { diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 7eb282ec..b64f6a16 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -23,7 +23,17 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { cn } from '@/lib/utils'; import { isDesktopShell } from '@/lib/desktop'; -import { ChatView, PlanView, GitView, DiffView, TerminalView, FilesView, SettingsView, SettingsWindow, MultiRunWindow } from '@/components/views'; +import { ChatView } from '@/components/views'; + +// Heavy views loaded on-demand to reduce initial bundle parse time. +const PlanView = React.lazy(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView }))); +const GitView = React.lazy(() => import('@/components/views/GitView').then(m => ({ default: m.GitView }))); +const DiffView = React.lazy(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView }))); +const TerminalView = React.lazy(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView }))); +const FilesView = React.lazy(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView }))); +const SettingsView = React.lazy(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); +const SettingsWindow = React.lazy(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); +const MultiRunWindow = React.lazy(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow }))); // Mobile drawer width as screen percentage const MOBILE_DRAWER_WIDTH_PERCENT = 85; @@ -596,15 +606,15 @@ export const MainLayout: React.FC = () => { const secondaryView = React.useMemo(() => { switch (activeMainTab) { case 'plan': - return ; + return ; case 'git': - return ; + return ; case 'diff': - return ; + return ; case 'terminal': - return ; + return ; case 'files': - return ; + return ; default: return null; } @@ -783,7 +793,7 @@ export const MainLayout: React.FC = () => { >
    - +
    @@ -824,7 +834,11 @@ export const MainLayout: React.FC = () => { className="absolute inset-0 z-10 bg-background" style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }} > - setSettingsDialogOpen(false)} /> + + + setSettingsDialogOpen(false)} /> + +
    )} @@ -934,7 +948,13 @@ export const MainLayout: React.FC = () => {
    - {isBottomTerminalOpen ? : null} + {isBottomTerminalOpen ? ( + + + + + + ) : null} { {/* Desktop settings: windowed dialog with blur */} - - + + + + + + )} diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 70a6e1ec..0be18d2f 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { ErrorBoundary } from '../ui/ErrorBoundary'; import { SessionSidebar } from '@/components/session/SessionSidebar'; -import { ChatView, SettingsView } from '@/components/views'; +import { ChatView } from '@/components/views'; + +const SettingsView = React.lazy(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); import { useSessionUIStore } from '@/sync/session-ui-store'; import { useViewportStore } from '@/sync/viewport-store'; import { useSessions, useDirectorySync } from '@/sync/sync-context'; @@ -388,10 +390,12 @@ export const VSCodeLayout: React.FC = () => { ) : currentView === 'settings' ? ( // Settings view - setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')} - forceMobile={usesMobileLayout} - /> + + setCurrentView(usesExpandedLayout ? 'chat' : 'sessions')} + forceMobile={usesMobileLayout} + /> + ) : usesExpandedLayout ? ( // Expanded layout: sessions sidebar + chat side by side
    diff --git a/packages/ui/src/lib/codemirror/languageByExtension.ts b/packages/ui/src/lib/codemirror/languageByExtension.ts index 90e6b5d1..ae4ace64 100644 --- a/packages/ui/src/lib/codemirror/languageByExtension.ts +++ b/packages/ui/src/lib/codemirror/languageByExtension.ts @@ -1,5 +1,8 @@ import type { Extension } from '@codemirror/state'; +// Static imports for the most common languages only. +// Less common languages are loaded dynamically via loadLanguageByExtension +// to keep the initial bundle lean. import { javascript } from '@codemirror/lang-javascript'; import { json } from '@codemirror/lang-json'; import { css } from '@codemirror/lang-css'; @@ -7,33 +10,12 @@ import { html } from '@codemirror/lang-html'; import { markdown } from '@codemirror/lang-markdown'; import { languages } from '@codemirror/language-data'; import { python } from '@codemirror/lang-python'; -import { sql } from '@codemirror/lang-sql'; -import { xml } from '@codemirror/lang-xml'; -import { yaml as yamlLanguage } from '@codemirror/lang-yaml'; -import { rust } from '@codemirror/lang-rust'; -import { elixir } from 'codemirror-lang-elixir'; -import { cpp } from '@codemirror/lang-cpp'; -import { go } from '@codemirror/lang-go'; import { Language, LanguageDescription, StreamLanguage, HighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { tags as t } from '@lezer/highlight'; import { shell } from '@codemirror/legacy-modes/mode/shell'; -import { toml } from '@codemirror/legacy-modes/mode/toml'; -import { diff } from '@codemirror/legacy-modes/mode/diff'; -import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'; -import { ruby } from '@codemirror/legacy-modes/mode/ruby'; -import { properties } from '@codemirror/legacy-modes/mode/properties'; -import { erlang } from '@codemirror/legacy-modes/mode/erlang'; const shellLanguage = StreamLanguage.define(shell); -const tomlLanguage = StreamLanguage.define(toml); -const diffLanguage = StreamLanguage.define(diff); -const dockerfileLanguage = StreamLanguage.define(dockerFile); -const rubyLanguage = StreamLanguage.define(ruby); -const propertiesLanguage = StreamLanguage.define(properties); -const elixirSupport = elixir(); -const elixirLanguage = elixirSupport.language; -const erlangLanguage = StreamLanguage.define(erlang); function codeBlockLanguageResolver(info: string): Language | LanguageDescription | null { const normalized = info.trim().toLowerCase(); @@ -46,11 +28,6 @@ function codeBlockLanguageResolver(info: string): Language | LanguageDescription case 'shellsession': case 'console': return shellLanguage; - case 'toml': - return tomlLanguage; - case 'diff': - case 'patch': - return diffLanguage; case 'json': case 'jsonc': case 'json5': @@ -65,39 +42,13 @@ function codeBlockLanguageResolver(info: string): Language | LanguageDescription return javascript({ typescript: true }).language; case 'tsx': return javascript({ typescript: true, jsx: true }).language; - case 'yaml': - case 'yml': - return yamlLanguage().language; case 'html': return html().language; case 'css': return css().language; - case 'xml': - case 'svg': - return xml().language; case 'py': case 'python': return python().language; - case 'sql': - return sql().language; - case 'rs': - case 'rust': - return rust().language; - case 'c': - case 'cpp': - case 'h': - case 'hpp': - return cpp().language; - case 'go': - return go().language; - case 'ex': - case 'exs': - case 'elixir': - return elixirLanguage; - case 'erl': - case 'hrl': - case 'erlang': - return erlangLanguage; case 'heex': case 'eex': case 'leex': @@ -135,8 +86,6 @@ export function languageByExtension(filePath: string): Extension | null { // Special filenames switch (filename) { - case 'dockerfile': - return dockerfileLanguage; case 'makefile': case 'gnumakefile': // No dedicated mode; shell is a decent fallback for Make-ish files. @@ -147,7 +96,7 @@ export function languageByExtension(filePath: string): Extension | null { const ext = idx >= 0 ? normalized.slice(idx + 1) : ''; switch (ext) { - // JavaScript/TypeScript + // JavaScript/TypeScript (most common — keep static) case 'ts': case 'tsx': case 'mts': @@ -159,7 +108,7 @@ export function languageByExtension(filePath: string): Extension | null { case 'cjs': return javascript({ typescript: false, jsx: ext === 'jsx' }); - // Web + // Web (keep static) case 'json': case 'jsonc': case 'json5': @@ -187,20 +136,7 @@ export function languageByExtension(filePath: string): Extension | null { markdownHighlight(), ]; - // Data/config - case 'yml': - case 'yaml': - return yamlLanguage(); - case 'toml': - return tomlLanguage; - case 'ini': - case 'cfg': - case 'conf': - case 'config': - case 'properties': - return propertiesLanguage; - - // Shell + // Shell (keep static) case 'sh': case 'bash': case 'zsh': @@ -208,52 +144,14 @@ export function languageByExtension(filePath: string): Extension | null { case 'env': return shellLanguage; - // Languages we already ship + // Python (very common — keep static) case 'py': case 'pyw': case 'pyi': return python(); - case 'sql': - case 'psql': - case 'plsql': - return sql(); - case 'xml': - case 'xsl': - case 'xslt': - case 'xsd': - case 'dtd': - case 'plist': - case 'svg': - return xml(); - case 'rs': - return rust(); - case 'c': - case 'cpp': - case 'h': - case 'hpp': - return cpp(); - case 'go': - return go(); - - // Legacy modes - case 'rb': - case 'erb': - case 'rake': - case 'gemspec': - return rubyLanguage; - - case 'ex': - case 'exs': - return elixirSupport; - case 'erl': - case 'hrl': - return erlangLanguage; - - case 'eex': - case 'leex': - case 'heex': - return html(); + // Less common languages: return null so callers fall back to + // loadLanguageByExtension which dynamically imports from @codemirror/language-data. default: return null; } diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 114a6716..f7af904b 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -1,7 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './styles/fonts' -import 'katex/dist/katex.min.css' import './index.css' import App from './App.tsx' import { SessionAuthGate } from './components/auth/SessionAuthGate' @@ -25,14 +24,23 @@ const runtimeAPIs = (typeof window !== 'undefined' && window.__OPENCHAMBER_RUNTI throw new Error('Runtime APIs not provided for legacy UI entrypoint.'); })(); -await Promise.all([ - syncDesktopSettings(), - initializeAppearancePreferences(), - applyPersistedDirectoryPreferences(), -]); -startAppearanceAutoSave(); -startModelPrefsAutoSave(); -startTypographyWatcher(); +// Keep appearance preferences blocking to avoid FOUC (flash of +// unstyled content) for users with non-default themes. Defer the +// remaining settings so they don't block first paint. +void initializeAppearancePreferences().then(() => { + void Promise.all([ + syncDesktopSettings(), + applyPersistedDirectoryPreferences(), + ]).then(() => { + startAppearanceAutoSave(); + startModelPrefsAutoSave(); + startTypographyWatcher(); + }).catch((err) => { + console.error('[main] settings init failed:', err); + }); +}).catch((err) => { + console.error('[main] appearance init failed:', err); +}); const rootElement = document.getElementById('root'); diff --git a/packages/ui/src/sync/bootstrap.ts b/packages/ui/src/sync/bootstrap.ts index feb805d3..4608381a 100644 --- a/packages/ui/src/sync/bootstrap.ts +++ b/packages/ui/src/sync/bootstrap.ts @@ -143,12 +143,15 @@ export async function bootstrapDirectory(input: { } if (loading) set({ status: "partial" }) - const results = await Promise.allSettled([ + // --------------------------------------------------------------------------- + // Phase 1: Critical path — block until these resolve so the UI can render. + // These are the minimum data needed to show a functional chat interface. + // --------------------------------------------------------------------------- + const phase1Results = await Promise.allSettled([ seededProject ? Promise.resolve() : retry(() => sdk.project.current().then((x) => set({ project: unwrap(x, "project.current").id }))), retry(() => sdk.provider.list().then((x) => set({ provider: unwrap(x, "provider.list") }))), - retry(() => sdk.app.agents().then((x) => set({ agent: unwrap(x, "app.agents") }))), retry(() => sdk.config.get().then((x) => set({ config: unwrap(x, "config.get") }))), retry(() => sdk.path.get().then((x) => { @@ -158,15 +161,37 @@ export async function bootstrapDirectory(input: { if (next) set({ project: next }) }), ), - retry(() => sdk.command.list().then((x) => set({ command: unwrap(x, "command.list") }))), retry(() => sdk.session.status().then((x) => set({ session_status: unwrap(x, "session.status") }))), - input.loadSessions(directory), + ]) + + const phase1Errors = phase1Results + .filter((r): r is PromiseRejectedResult => r.status === "rejected") + .map((r) => r.reason) + + // path.get (index 3) and session.status (index 4) have no global-state + // fallback. If either fails, the UI cannot safely advance to "complete". + const criticalPhase1Failed = phase1Results[3].status === "rejected" || phase1Results[4].status === "rejected" + + if (phase1Errors.length === phase1Results.length || criticalPhase1Failed) { + console.error(`[bootstrap] directory bootstrap failed for ${directory}`, phase1Errors[0]) + return + } + + // Mark ready after critical data arrives so the UI can paint. + if (loading) set({ status: "complete" }) + + // --------------------------------------------------------------------------- + // Phase 2: Deferrable — fetch after first paint without blocking. + // These enrich the UI but aren't required for basic functionality. + // --------------------------------------------------------------------------- + void Promise.allSettled([ + retry(() => sdk.app.agents().then((x) => set({ agent: unwrap(x, "app.agents") }))), + retry(() => sdk.command.list().then((x) => set({ command: unwrap(x, "command.list") }))), retry(() => sdk.mcp.status().then((x) => set({ mcp: unwrap(x, "mcp.status") }))), retry(() => sdk.lsp.status().then((x) => set({ lsp: unwrap(x, "lsp.status") }))), retry(() => sdk.vcs.get().then((x) => { const current = getState() - // vcs is optional — fall back to current if server omits it. if (x.error) { throw new Error(`vcs.get failed: ${String(x.error)}`) } @@ -179,30 +204,30 @@ export async function bootstrapDirectory(input: { Object.entries(before.question ?? {}).map(([sessionID, questions]) => [sessionID, requestSignature(questions)]), ) const x = await sdk.question.list(directory ? { directory } : undefined) - if (x.error) { - const status = (x as { response?: { status?: number } }).response?.status - const err = new Error(`question.list failed${status ? ` (${status})` : ""}: ${String(x.error)}`) - if (status !== undefined) (err as Error & { status?: number }).status = status - throw err - } - const grouped = groupBySession( - (x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID), - ) - const current = getState() - const merged = { ...current.question } - for (const [sessionID, questions] of Object.entries(grouped)) { - merged[sessionID] = questions - .filter((q) => !!q?.id) - .sort((a, b) => cmp(a.id, b.id)) - } - for (const sessionID of beforeSignatures.keys()) { - if (grouped[sessionID]) continue - const beforeSignature = beforeSignatures.get(sessionID) ?? "" - const currentSignature = requestSignature(current.question[sessionID]) - if (currentSignature !== beforeSignature) continue - delete merged[sessionID] - } - set({ question: merged }) + if (x.error) { + const status = (x as { response?: { status?: number } }).response?.status + const err = new Error(`question.list failed${status ? ` (${status})` : ""}: ${String(x.error)}`) + if (status !== undefined) (err as Error & { status?: number }).status = status + throw err + } + const grouped = groupBySession( + (x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID), + ) + const current = getState() + const merged = { ...current.question } + for (const [sessionID, questions] of Object.entries(grouped)) { + merged[sessionID] = questions + .filter((q) => !!q?.id) + .sort((a, b) => cmp(a.id, b.id)) + } + for (const sessionID of beforeSignatures.keys()) { + if (grouped[sessionID]) continue + const beforeSignature = beforeSignatures.get(sessionID) ?? "" + const currentSignature = requestSignature(current.question[sessionID]) + if (currentSignature !== beforeSignature) continue + delete merged[sessionID] + } + set({ question: merged }) }), retry(async () => { const before = getState() @@ -210,40 +235,44 @@ export async function bootstrapDirectory(input: { Object.entries(before.permission ?? {}).map(([sessionID, permissions]) => [sessionID, requestSignature(permissions)]), ) const x = await sdk.permission.list(directory ? { directory } : undefined) - if (x.error) { - const status = (x as { response?: { status?: number } }).response?.status - const err = new Error(`permission.list failed${status ? ` (${status})` : ""}: ${String(x.error)}`) - if (status !== undefined) (err as Error & { status?: number }).status = status - throw err - } - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm?.sessionID), - ) - const current = getState() - const merged = { ...current.permission } - for (const [sessionID, perms] of Object.entries(grouped)) { - merged[sessionID] = perms - .filter((p) => !!p?.id) - .sort((a, b) => cmp(a.id, b.id)) - } - for (const sessionID of beforeSignatures.keys()) { - if (grouped[sessionID]) continue - const beforeSignature = beforeSignatures.get(sessionID) ?? "" - const currentSignature = requestSignature(current.permission[sessionID]) - if (currentSignature !== beforeSignature) continue - delete merged[sessionID] - } - set({ permission: merged }) + if (x.error) { + const status = (x as { response?: { status?: number } }).response?.status + const err = new Error(`permission.list failed${status ? ` (${status})` : ""}: ${String(x.error)}`) + if (status !== undefined) (err as Error & { status?: number }).status = status + throw err + } + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm?.sessionID), + ) + const current = getState() + const merged = { ...current.permission } + for (const [sessionID, perms] of Object.entries(grouped)) { + merged[sessionID] = perms + .filter((p) => !!p?.id) + .sort((a, b) => cmp(a.id, b.id)) + } + for (const sessionID of beforeSignatures.keys()) { + if (grouped[sessionID]) continue + const beforeSignature = beforeSignatures.get(sessionID) ?? "" + const currentSignature = requestSignature(current.permission[sessionID]) + if (currentSignature !== beforeSignature) continue + delete merged[sessionID] + } + set({ permission: merged }) }), - ]) + ]).then((results) => { + const errors = results + .filter((r): r is PromiseRejectedResult => r.status === "rejected") + .map((r) => r.reason) + if (errors.length) { + console.error(`[bootstrap] deferred phase failed for ${directory}`, errors[0]) + } + }) - const errors = results - .filter((r): r is PromiseRejectedResult => r.status === "rejected") - .map((r) => r.reason) - if (errors.length) { - console.error(`[bootstrap] directory bootstrap failed for ${directory}`, errors[0]) - return - } - - if (loading) set({ status: "complete" }) + // --------------------------------------------------------------------------- + // Phase 3: Lazy — session list can be large; don't block on it. + // --------------------------------------------------------------------------- + void Promise.resolve(input.loadSessions(directory)).catch((err) => { + console.error(`[bootstrap] session load failed for ${directory}`, err) + }) } diff --git a/packages/ui/src/sync/streaming.ts b/packages/ui/src/sync/streaming.ts index d178a1dc..b117f7f1 100644 --- a/packages/ui/src/sync/streaming.ts +++ b/packages/ui/src/sync/streaming.ts @@ -35,80 +35,80 @@ export const useStreamingStore = create()(() => ({ * Called from the SyncBridge/flush handler when child store state changes. * Derives streaming state from session_status + messages. */ +/** Only update lastUpdateAt every this many ms to avoid 60Hz store churn */ +const STREAMING_HEARTBEAT_MS = 1000 + export function updateStreamingState(state: State) { const now = Date.now() + const currentStore = useStreamingStore.getState() + const currentStreamingIds = currentStore.streamingMessageIds + const currentStreamStates = currentStore.messageStreamStates + const nextStreamingIds = new Map() - const nextStreamStates = new Map(useStreamingStore.getState().messageStreamStates) + const nextStreamStates = new Map(currentStreamStates) let changed = false + // Fast path: only scan sessions that are actually busy. + // Idle sessions are handled by checking against currentStreamingIds below. + const busySessionIds = new Set() for (const [sessionID, status] of Object.entries(state.session_status ?? {})) { - const isBusy = (status as SessionStatus).type === "busy" - const messages = state.message[sessionID] - - if (isBusy && messages && messages.length > 0) { - // Find the last assistant message — that's the one streaming - let streamingMsg: Message | null = null - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "assistant") { - streamingMsg = messages[i] - break - } - } - - if (streamingMsg) { - const prevId = nextStreamingIds.get(sessionID) - if (prevId !== streamingMsg.id) changed = true - nextStreamingIds.set(sessionID, streamingMsg.id) - - const existing = nextStreamStates.get(streamingMsg.id) - if (!existing || existing.phase !== "streaming") { - nextStreamStates.set(streamingMsg.id, { - phase: "streaming", - startedAt: existing?.startedAt ?? now, - lastUpdateAt: now, - }) - changed = true - } else if (existing.lastUpdateAt !== now) { - nextStreamStates.set(streamingMsg.id, { - ...existing, - lastUpdateAt: now, - }) - changed = true - } - } - } else { - // Session is idle — check if we had a streaming message - const prev = useStreamingStore.getState().streamingMessageIds.get(sessionID) - if (prev) { - nextStreamingIds.set(sessionID, null) - const existing = nextStreamStates.get(prev) - if (existing && existing.phase === "streaming") { - // Transition to cooldown then completed - nextStreamStates.set(prev, { - ...existing, - phase: "completed", - completedAt: now, - }) - changed = true - } - } + if ((status as SessionStatus).type === "busy") { + busySessionIds.add(sessionID) } } - // Also mark completed any streaming messages for sessions no longer in status - const currentIds = useStreamingStore.getState().streamingMessageIds - for (const [sessionID, msgId] of currentIds) { - if (msgId && !state.session_status?.[sessionID]) { - const existing = nextStreamStates.get(msgId) - if (existing && existing.phase === "streaming") { - nextStreamStates.set(msgId, { - ...existing, - phase: "completed", - completedAt: now, - }) - changed = true + for (const sessionID of busySessionIds) { + const messages = state.message[sessionID] + if (!messages || messages.length === 0) continue + + // Find the last assistant message — that's the one streaming + let streamingMsg: Message | null = null + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "assistant") { + streamingMsg = messages[i] + break } - nextStreamingIds.set(sessionID, null) + } + + if (!streamingMsg) continue + + const prevId = currentStreamingIds.get(sessionID) + if (prevId !== streamingMsg.id) changed = true + nextStreamingIds.set(sessionID, streamingMsg.id) + + const existing = nextStreamStates.get(streamingMsg.id) + if (!existing || existing.phase !== "streaming") { + nextStreamStates.set(streamingMsg.id, { + phase: "streaming", + startedAt: existing?.startedAt ?? now, + lastUpdateAt: now, + }) + changed = true + } else if (now - existing.lastUpdateAt >= STREAMING_HEARTBEAT_MS) { + // Throttle lastUpdateAt writes to ~1Hz instead of 60Hz + nextStreamStates.set(streamingMsg.id, { + ...existing, + lastUpdateAt: now, + }) + changed = true + } + } + + // Mark completed any previously streaming sessions that are now idle or gone + for (const [sessionID, msgId] of currentStreamingIds) { + if (!msgId) continue + const isStillBusy = busySessionIds.has(sessionID) + if (isStillBusy) continue + + nextStreamingIds.set(sessionID, null) + const existing = nextStreamStates.get(msgId) + if (existing && existing.phase === "streaming") { + nextStreamStates.set(msgId, { + ...existing, + phase: "completed", + completedAt: now, + }) + changed = true } } diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 9f618d94..27560c95 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -99,7 +99,7 @@ export default defineConfig({ build: { outDir: path.resolve(__dirname, 'dist'), emptyOutDir: true, - chunkSizeWarningLimit: 1200, + chunkSizeWarningLimit: 500, rollupOptions: { external: ['node:child_process', 'node:fs', 'node:path', 'node:url'], output: {