Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 20 additions & 10 deletions src/renderer/components/chat/markdownComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { api } from '@renderer/api';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { PROSE_BODY } from '@renderer/constants/cssVariables';

import { MermaidViewer } from './viewers/MermaidViewer';
const MermaidViewer = React.lazy(() =>
import('./viewers/MermaidViewer').then((m) => ({ default: m.MermaidViewer }))
);
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';

import type { Components } from 'react-markdown';
Comment on lines +7 to 12
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simple-import-sort/imports (eslint.config.js) expects imports to be grouped/sorted. Declaring const MermaidViewer = React.lazy(...) between import declarations will likely break lint; keep all imports together, then declare MermaidViewer below them (or extract the lazy import to a separate module).

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -129,7 +131,11 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo
const text = content.replace(/\n$/, '');

if (lang === 'mermaid') {
return <MermaidViewer code={text} />;
return (
<React.Suspense fallback={<code className="block font-mono text-xs">{text}</code>}>
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Suspense fallback returns a <code> element without white-space: pre / whitespace-pre or a <pre> wrapper. For mermaid fences this will typically collapse newlines and indentation while the lazy component loads; use a block fallback that preserves formatting (e.g., <pre> + <code> or add whitespace-pre).

Suggested change
<React.Suspense fallback={<code className="block font-mono text-xs">{text}</code>}>
<React.Suspense
fallback={
<pre className="font-mono text-xs whitespace-pre">
<code>{text}</code>
</pre>
}
>

Copilot uses AI. Check for mistakes.
<MermaidViewer code={text} />
</React.Suspense>
);
}

return (
Expand All @@ -153,18 +159,22 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo
},

// Code blocks — skip <pre> wrapper for mermaid diagrams, with copy button
pre: ({ children }) => {
const child = React.Children.only(children) as React.ReactElement;
if (child?.type === MermaidViewer) {
pre: ({ children, node }) => {
// Detect mermaid: check if the <code> child has class "language-mermaid"
const codeEl = node?.children?.find((c) => 'tagName' in c && c.tagName === 'code') as
| { properties?: { className?: string[] } }
| undefined;
const isMermaid = codeEl?.properties?.className?.includes('language-mermaid');
Comment on lines +165 to +167
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

codeEl?.properties?.className?.includes('language-mermaid') can mis-detect when className is a string containing the substring (e.g. language-mermaid2). Consider checking exact class tokens (handle string | string[]) to avoid skipping the <pre> wrapper for non-mermaid code blocks.

Suggested change
| { properties?: { className?: string[] } }
| undefined;
const isMermaid = codeEl?.properties?.className?.includes('language-mermaid');
| { properties?: { className?: string | string[] } }
| undefined;
const classNames = codeEl?.properties?.className;
const classTokens =
typeof classNames === 'string'
? classNames.split(/\s+/).filter(Boolean)
: Array.isArray(classNames)
? classNames
: [];
const isMermaid = classTokens.includes('language-mermaid');

Copilot uses AI. Check for mistakes.
if (isMermaid) {
return children as React.ReactElement;
}

// Extract text from nested <code> children for the copy button
const extractText = (node: React.ReactNode): string => {
if (typeof node === 'string') return node;
if (Array.isArray(node)) return node.map(extractText).join('');
if (React.isValidElement(node) && node.props) {
const props = node.props as { children?: React.ReactNode };
const extractText = (child: React.ReactNode): string => {
if (typeof child === 'string') return child;
if (Array.isArray(child)) return child.map(extractText).join('');
if (React.isValidElement(child) && child.props) {
const props = child.props as { children?: React.ReactNode };
return extractText(props.children);
}
return '';
Expand Down
19 changes: 14 additions & 5 deletions src/renderer/components/chat/viewers/MarkdownViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ import {
highlightSearchInChildren,
type SearchContext,
} from '../searchHighlightUtils';
import { MermaidViewer } from '../viewers/MermaidViewer';
const MermaidViewer = React.lazy(() =>
import('../viewers/MermaidViewer').then((m) => ({ default: m.MermaidViewer }))
);
import { highlightLine } from '../viewers/syntaxHighlighter';
Comment on lines +36 to 39
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simple-import-sort/imports (see eslint.config.js import-sorting rules) expects all import declarations to be grouped/sorted together. Having import { highlightLine ... } after the const MermaidViewer = React.lazy(...) statement will likely fail lint; move the highlightLine import above and keep the lazy wrapper after the last import (or extract it to a separate module).

Suggested change
const MermaidViewer = React.lazy(() =>
import('../viewers/MermaidViewer').then((m) => ({ default: m.MermaidViewer }))
);
import { highlightLine } from '../viewers/syntaxHighlighter';
import { highlightLine } from '../viewers/syntaxHighlighter';
const MermaidViewer = React.lazy(() =>
import('../viewers/MermaidViewer').then((m) => ({ default: m.MermaidViewer }))
);

Copilot uses AI. Check for mistakes.

// =============================================================================
Expand Down Expand Up @@ -162,7 +164,11 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
const text = raw.replace(/\n$/, '');

if (lang === 'mermaid') {
return <MermaidViewer code={text} />;
return (
<React.Suspense fallback={<code className="font-mono text-xs">{text}</code>}>
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Suspense fallback renders a bare <code> element; since mermaid source is typically multiline, newlines/indentation may collapse without white-space: pre/whitespace-pre (or a <pre> wrapper). Consider using a <pre>/code block-style fallback that preserves formatting so the initial render doesn't look broken while the lazy chunk loads.

Suggested change
<React.Suspense fallback={<code className="font-mono text-xs">{text}</code>}>
<React.Suspense
fallback={
<pre className="font-mono text-xs whitespace-pre" style={{ color: COLOR_TEXT }}>
<code>{text}</code>
</pre>
}
>

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The fallback <code> element is missing the block class, which is present in the equivalent fallback in markdownComponents.tsx. Since this component replaces a block-level element (the <pre> wrapper is skipped for mermaid), it should be rendered as a block to maintain layout consistency while loading.

Suggested change
<React.Suspense fallback={<code className="font-mono text-xs">{text}</code>}>
<React.Suspense fallback={<code className="block font-mono text-xs">{text}</code>}>

<MermaidViewer code={text} />
</React.Suspense>
);
Comment on lines +167 to +171
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Render the Mermaid lazy fallback as a block with preserved whitespace.

Once pre returns Mermaid children directly, this fallback is the only UI during the first chunk load. As an inline <code> without whitespace preservation, multi-line diagrams collapse into unreadable text until the import resolves. src/renderer/components/chat/markdownComponents.tsx at Lines 135-137 has a parallel fallback, so this is a good place to share one block-style fallback.

💡 Minimal fix
-            <React.Suspense fallback={<code className="font-mono text-xs">{text}</code>}>
+            <React.Suspense
+              fallback={
+                <code
+                  className="block whitespace-pre-wrap font-mono text-xs"
+                  style={{ color: COLOR_TEXT }}
+                >
+                  {text}
+                </code>
+              }
+            >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/chat/viewers/MarkdownViewer.tsx` around lines 167 -
171, The Suspense fallback in MarkdownViewer currently renders an inline <code>
element which collapses multi-line Mermaid diagrams; change the fallback used in
the <React.Suspense> wrapping MermaidViewer to the same block-style,
whitespace-preserving fallback used in
src/renderer/components/chat/markdownComponents.tsx (i.e., render a block
element that preserves whitespace such as a <pre> with the same
classes/structure used there) so multi-line diagrams remain readable while the
lazy import resolves; update the fallback in the React.Suspense inside
MarkdownViewer around MermaidViewer accordingly.

}

const lines = text.split('\n');
Expand Down Expand Up @@ -192,9 +198,12 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
},

// Code blocks — skip <pre> wrapper for mermaid diagrams
pre: ({ children }) => {
const child = React.Children.only(children) as React.ReactElement;
if (child?.type === MermaidViewer) {
pre: ({ children, node }) => {
const codeEl = node?.children?.find((c) => 'tagName' in c && c.tagName === 'code') as
| { properties?: { className?: string[] } }
| undefined;
const isMermaid = codeEl?.properties?.className?.includes('language-mermaid');
Comment on lines +203 to +205
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

codeEl?.properties?.className?.includes('language-mermaid') can produce false positives when className is a string and merely contains the substring (e.g. language-mermaid2). To avoid skipping the <pre> wrapper incorrectly, treat className as string | string[] and check for an exact token match (=== 'language-mermaid' in the array, or split string on whitespace and match tokens).

Suggested change
| { properties?: { className?: string[] } }
| undefined;
const isMermaid = codeEl?.properties?.className?.includes('language-mermaid');
| { properties?: { className?: string | string[] } }
| undefined;
const codeClassName = codeEl?.properties?.className;
const isMermaid = Array.isArray(codeClassName)
? codeClassName.some((token) => token === 'language-mermaid')
: typeof codeClassName === 'string'
? codeClassName.split(/\s+/).some((token) => token === 'language-mermaid')
: false;

Copilot uses AI. Check for mistakes.
if (isMermaid) {
return children as React.ReactElement;
}
return (
Expand Down
39 changes: 25 additions & 14 deletions src/renderer/components/chat/viewers/MermaidViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,37 @@ import {
} from '@renderer/constants/cssVariables';
import { useTheme } from '@renderer/hooks/useTheme';
import { Code, GitBranch } from 'lucide-react';
import mermaid from 'mermaid';

import type mermaidApi from 'mermaid';

// =============================================================================
// Mermaid initialization
// Mermaid initialization (lazy-loaded to keep it out of the main bundle)
// =============================================================================

let mermaidInstance: typeof mermaidApi | null = null;
let lastMermaidTheme: 'dark' | 'default' | null = null;

function ensureMermaidInit(isDark: boolean): void {
async function getMermaid(): Promise<typeof mermaidApi> {
if (!mermaidInstance) {
const mod = await import('mermaid');
mermaidInstance = mod.default;
}
return mermaidInstance;
}

async function ensureMermaidInit(isDark: boolean): Promise<typeof mermaidApi> {
const m = await getMermaid();
const theme: 'dark' | 'default' = isDark ? 'dark' : 'default';
if (lastMermaidTheme === theme) {
return;
if (lastMermaidTheme !== theme) {
m.initialize({
startOnLoad: false,
theme,
securityLevel: 'strict',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
});
lastMermaidTheme = theme;
}
mermaid.initialize({
startOnLoad: false,
theme,
securityLevel: 'strict',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
});
lastMermaidTheme = theme;
return m;
}

// =============================================================================
Expand All @@ -53,9 +64,9 @@ export const MermaidViewer: React.FC<MermaidViewerProps> = ({ code }) => {
let cancelled = false;
const render = async (): Promise<void> => {
try {
ensureMermaidInit(isDark);
const m = await ensureMermaidInit(isDark);
const id = `mermaid-${uniqueId}`;
const { svg: rendered } = await mermaid.render(id, code);
const { svg: rendered } = await m.render(id, code);
if (!cancelled) {
setSvg(rendered);
setError(null);
Expand Down
Loading