Skip to content
Open
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
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"highlight.js": "^11.11.1",
"mermaid": "^11.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
Expand Down
87 changes: 87 additions & 0 deletions frontend/src/components/MermaidBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useEffect, useId, useState } from 'react';
import { CopyButton } from './CopyButton';

let mermaidInitialized = false;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: The module-level mermaidInitialized flag works in production but can leak state between test runs in Vitest (which reuses the module cache by default). If MermaidBlock tests are added later, they may see stale initialization. Not a production issue, but worth noting for testability.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: The module-level mermaidInitialized flag means mermaid.initialize() is only ever called once with the hardcoded dark theme. If theme support is added later (light/dark toggle), re-initialization would be silently skipped. This is fine for now but worth noting as a design constraint.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: Module-level let mermaidInitialized is mutable shared state. If tests reset modules or if HMR reloads the component without reloading the module, the flag could get out of sync. Consider using mermaid.initialize idempotency or checking mermaid internal state instead.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: Module-level mermaidInitialized flag won't reset during Vite HMR — if mermaid config (theme, security) needs updating during development, a full page reload is required. Minor DX issue; not a production concern.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: The module-level mermaidInitialized flag never resets. In tests this creates ordering dependence — test 3 (renders SVG...) sets it to true, so any later test expecting mermaid.initialize to be called again will fail silently. Consider exporting a resetMermaidInit() for test use, or using vi.resetModules() in the test's beforeEach. [fixable]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: The module-level mermaidInitialized flag is never reset between tests. The MermaidBlock tests work because renderToStaticMarkup (tests 1-2) doesn't trigger useEffect, so test 3 is the first to call ensureMermaidInit(). If test ordering changes or new tests are added that use render() before test 3, the mermaid.initialize assertion in test 3 would fail. Consider exporting a resetMermaidInit for test use, or moving the flag into a ref/context. [fixable]


interface MermaidBlockProps {
code: string;
}

export function MermaidBlock({ code }: MermaidBlockProps) {
const instanceId = useId();
const [error, setError] = useState<string | null>(null);
const [svg, setSvg] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
const id = `mermaid-${instanceId.replace(/:/g, '')}`;

async function render() {
try {
// Dynamic import keeps mermaid (~1MB+ with d3/katex/cytoscape) out of
// the main bundle — only loaded when a mermaid diagram is encountered.
const { default: mermaid } = await import('mermaid');
if (!mermaidInitialized) {
mermaidInitialized = true;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 bugs: mermaidInitialized is set to true before mermaid.initialize() executes (line 30 runs before line 31). If initialize() throws synchronously, the flag stays true and all future MermaidBlock instances skip initialization — mermaid.render() then runs against an uninitialized library. Move the flag assignment after the initialize() call so a failed init can be retried. [fixable]

mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
theme: 'dark',
themeVariables: {
darkMode: true,
background: '#1e1e2e',
primaryColor: '#7c3aed',
primaryTextColor: '#e2e8f0',
primaryBorderColor: '#6366f1',
lineColor: '#94a3b8',
secondaryColor: '#374151',
tertiaryColor: '#1f2937',
noteBkgColor: '#374151',
noteTextColor: '#e2e8f0',
fontFamily: 'inherit',
},
});
}
const { svg: rendered } = await mermaid.render(id, code);
if (!cancelled) {
setSvg(rendered);
setError(null);
}
} catch {
if (!cancelled) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: The DOM cleanup document.getElementById(d${id})?.remove() assumes mermaid's internal temp element naming convention (d prefix). This is an undocumented implementation detail of mermaid that could change across versions. Consider wrapping the mermaid render call in a try/finally that queries by the known container pattern, or document the dependency on mermaid internals with a version-pinned comment.

setError('Invalid diagram');
setSvg(null);
// Mermaid inserts a temporary element with id `d<id>` during render.
// On error, it may leave this element behind. Convention verified
// against mermaid v11 (mermaid-js/mermaid).
document.getElementById(`d${id}`)?.remove();
}
}
}

render();
return () => {
cancelled = true;
};
}, [code, instanceId]);

if (error) {
return (
<div className="code-block-wrapper">
<pre>
<code>{code}</code>
</pre>
<CopyButton text={code} className="code-block-copy" label="Copy code" />
</div>
);
}

if (!svg) return null;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: When svg is null and there's no error (initial render / loading state), the component returns null — a blank gap in the message. Consider rendering a lightweight loading indicator (e.g., a skeleton or the raw code block) so the user sees something while mermaid loads (~1MB async import + render). [fixable]


return (
<div className="mermaid-block">
<div className="mermaid-block-svg" dangerouslySetInnerHTML={{ __html: svg }} />

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 unsafe_assumptions: dangerouslySetInnerHTML={{ __html: svg }} relies entirely on mermaid's securityLevel: 'strict' (which uses DOMPurify internally) for XSS safety. This is the standard mermaid integration pattern and is safe as long as the mermaid library itself isn't compromised. However, if the mermaid dependency were supply-chain attacked, SVG would be injected unsanitized. Consider an explicit DOMPurify pass on the SVG output as defense-in-depth, or at minimum a comment documenting the safety invariant. [fixable]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 unsafe_assumptions: Using dangerouslySetInnerHTML={{ __html: svg }} with mermaid's rendered SVG. While mermaid v11 with securityLevel: 'strict' uses DOMPurify internally to sanitize output (DOMPurify is a transitive dependency visible in the lockfile), this is an implicit safety guarantee tied to mermaid's internals. If mermaid ever changes its sanitization behavior, this becomes an XSS vector. Consider adding a brief comment noting the DOMPurify reliance, or adding an explicit DOMPurify.sanitize() call on the SVG output for defense in depth. [fixable]

<CopyButton text={code} className="code-block-copy" label="Copy source" />
</div>
);
}
143 changes: 78 additions & 65 deletions frontend/src/components/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
Expand All @@ -10,7 +10,9 @@ import { CopyButton } from './CopyButton';
import { ShareButton } from './ShareButton';
import { ReadAloudButton } from './ReadAloudButton';
import { extractText } from '../lib/extractText';
import { getMermaidCode } from '../lib/mermaid-detect';
import { MarkdownPreviewCard } from './MarkdownPreviewCard';
import { MermaidBlock } from './MermaidBlock';

const COLLAPSE_HEIGHT = 300;

Expand Down Expand Up @@ -88,11 +90,16 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }:
const navigate = useNavigate();
const location = useLocation();
const processed = streaming ? content : linkifyFilePaths(content);
const currentPath = location.pathname + location.search;
const [collapsed, setCollapsed] = useState(true);
const [isLong, setIsLong] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);

// Use a ref for currentPath so the useMemo components stay stable across
// location changes (query params, navigation). The onClick handler reads
// the ref at click time, not at memo creation time.
const currentPathRef = useRef(location.pathname + location.search);
currentPathRef.current = location.pathname + location.search;

useEffect(() => {
if (contentRef.current && !streaming) {
setIsLong(contentRef.current.scrollHeight > COLLAPSE_HEIGHT);
Expand All @@ -101,6 +108,74 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }:

const showCollapsed = isLong && collapsed && !streaming;

// Memoize components so react-markdown preserves component instances
// (e.g. MarkdownPreviewCard expanded state) across parent re-renders.
// navigate is stable (from react-router), currentPath uses a ref to avoid
// invalidating the memo on location changes.
const mdComponents = useMemo(
() => ({
table: ({ children, ...props }: React.ComponentProps<'table'>) => (
<div className="table-scroll-wrapper">
<table {...props}>{children}</table>
</div>
),
pre: ({ children, ...props }: React.ComponentProps<'pre'>) => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The mermaid detection logic (extracting the first child, checking className against /language-mermaid/) is duplicated verbatim between MessageBubble.tsx:114-123 and markdown-config.tsx:29-37. Extract a shared helper (e.g., isMermaidCodeBlock(children): string | null) to keep the two call sites in sync. [fixable]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🔵 style: The mermaid-aware pre component is duplicated between MessageBubble.tsx (line 115-124) and markdown-config.tsx (line 28-32). Both call getMermaidCode and return MermaidBlock identically; only the non-mermaid fallback differs (CopyButton wrapper vs. plain

). Consider extracting the shared mermaid detection + fallback pattern, or having MessageBubble compose on top of markdown-config's pre. [fixable]

const mermaidCode = getMermaidCode(children);
if (mermaidCode !== null) return <MermaidBlock code={mermaidCode} />;
const text = extractText(children);
return (
<div className="code-block-wrapper">
<pre {...props}>{children}</pre>
<CopyButton text={text} className="code-block-copy" label="Copy code" />
</div>
);
},
p: ({ children }: React.ComponentProps<'p'>) => {
const childArray = React.Children.toArray(children);
if (childArray.length === 1 && React.isValidElement(childArray[0])) {
const el = childArray[0] as React.ReactElement<Record<string, unknown>>;
const href = el.props?.href as string | undefined;
if (href?.startsWith(FILE_SCHEME)) {
const filePath = decodeURIComponent(href.slice(FILE_SCHEME.length));
if (/\.mdx?$/i.test(filePath)) {
return <MarkdownPreviewCard filePath={filePath} />;
}
}
}
return <p>{children}</p>;
},
a: ({ href, children }: React.ComponentProps<'a'>) => {
if (href?.startsWith(FILE_SCHEME)) {
const filePath = decodeURIComponent(href.slice(FILE_SCHEME.length));
return (
<span className="file-path-group">
<a
href="#"
className="file-path-link"
data-file-path={filePath}
onClick={(e) => {
e.preventDefault();
navigate(
`/files?path=${encodeURIComponent(filePath)}&from=${encodeURIComponent(currentPathRef.current)}`,
);
}}
>
{children}
</a>
<ShareButton filePath={filePath} className="file-path-share" />
</span>
);
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
},
}),
[navigate],
);

return (
<div
className={`msg-bubble msg-bubble--assistant${streaming ? ' msg-bubble--streaming' : ''}${showCollapsed ? ' msg-bubble--collapsed' : ''}`}
Expand All @@ -110,69 +185,7 @@ export function TextBubble({ content, streaming = false, timestamp, readAloud }:
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
urlTransform={(url) => (url.startsWith(FILE_SCHEME) ? url : defaultUrlTransform(url))}
components={{
table: ({ children, ...props }) => (
<div className="table-scroll-wrapper">
<table {...props}>{children}</table>
</div>
),
pre: ({ children, ...props }) => {
const text = extractText(children);
return (
<div className="code-block-wrapper">
<pre {...props}>{children}</pre>
<CopyButton text={text} className="code-block-copy" label="Copy code" />
</div>
);
},
// When a paragraph contains a single file-path link to a .md/.mdx
// file, promote it to an inline preview card instead of a plain link.
// In ReactMarkdown v10, children are unrendered component instances —
// the `a` handler hasn't run yet — so we check `href` (the prop
// ReactMarkdown passes) rather than rendered DOM attributes.
p: ({ children }) => {
const childArray = React.Children.toArray(children);
if (childArray.length === 1 && React.isValidElement(childArray[0])) {
const el = childArray[0] as React.ReactElement<Record<string, unknown>>;
const href = el.props?.href as string | undefined;
if (href?.startsWith(FILE_SCHEME)) {
const filePath = decodeURIComponent(href.slice(FILE_SCHEME.length));
if (/\.mdx?$/i.test(filePath)) {
return <MarkdownPreviewCard filePath={filePath} />;
}
}
}
return <p>{children}</p>;
},
a: ({ href, children }) => {
if (href?.startsWith(FILE_SCHEME)) {
const filePath = decodeURIComponent(href.slice(FILE_SCHEME.length));
return (
<span className="file-path-group">
<a
href="#"
className="file-path-link"
data-file-path={filePath}
onClick={(e) => {
e.preventDefault();
navigate(
`/files?path=${encodeURIComponent(filePath)}&from=${encodeURIComponent(currentPath)}`,
);
}}
>
{children}
</a>
<ShareButton filePath={filePath} className="file-path-share" />
</span>
);
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
},
}}
components={mdComponents}
>
{processed}
</ReactMarkdown>
Expand Down
107 changes: 107 additions & 0 deletions frontend/src/components/__tests__/MermaidBlock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createElement } from 'react';
import { render, act, cleanup } from '@testing-library/react';

const mockInitialize = vi.fn();
const mockRender = vi.fn();

// Mock the dynamic import('mermaid') that MermaidBlock uses
vi.mock('mermaid', () => ({
default: {
initialize: mockInitialize,
render: mockRender,
},
}));

beforeEach(() => {
vi.clearAllMocks();
cleanup();
});

// Each test that needs a fresh module-level `mermaidInitialized` flag uses
// vi.resetModules() + dynamic import, avoiding a test-only export.
async function freshMermaidBlock() {
vi.resetModules();
const mod = await import('../MermaidBlock');
return mod.MermaidBlock;
}

describe('MermaidBlock', () => {
it('renders SVG and initializes with securityLevel strict', async () => {
mockRender.mockResolvedValue({
svg: '<svg>diagram</svg>',
diagramType: 'flowchart',
bindFunctions: undefined,
});
const MermaidBlock = await freshMermaidBlock();
await act(async () => {
render(createElement(MermaidBlock, { code: 'graph TD; A-->B;' }));
});
const block = document.querySelector('.mermaid-block-svg');
expect(block).not.toBeNull();
expect(block!.innerHTML).toContain('diagram');
expect(mockInitialize).toHaveBeenCalledWith(
expect.objectContaining({ securityLevel: 'strict' }),
);
});

it('renders fallback code block on render error', async () => {
mockRender.mockRejectedValue(new Error('parse error'));
const MermaidBlock = await freshMermaidBlock();
await act(async () => {
render(createElement(MermaidBlock, { code: 'invalid{{{' }));
});
const wrapper = document.querySelector('.code-block-wrapper');
expect(wrapper).not.toBeNull();
expect(wrapper!.textContent).toContain('invalid{{{');
});

it('only initializes mermaid once across multiple renders', async () => {
mockRender.mockResolvedValue({
svg: '<svg>a</svg>',
diagramType: 'flowchart',
bindFunctions: undefined,
});
// Get a fresh module (resets mermaidInitialized flag)
const Fresh = await freshMermaidBlock();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

🟡 bugs: The 'only initializes once' test has a dead import on line 69: const { MermaidBlock } = await import('../MermaidBlock') is executed, populating the module cache, but MermaidBlock is never used. Then freshMermaidBlock() calls vi.resetModules() which clears that cache, making the import meaningless. The test works correctly (Fresh and Same come from the same post-reset module instance), but the dead import is confusing and suggests the test was written with a misunderstanding of the module caching mechanics. Remove line 69 for clarity. [fixable]

await act(async () => {
render(createElement(Fresh, { code: 'graph TD; A-->B;' }));
});
cleanup();
// Re-import from cache (same module instance, flag already set)
const { MermaidBlock: Same } = await import('../MermaidBlock');
await act(async () => {
render(createElement(Same, { code: 'graph LR; X-->Y;' }));
});
expect(mockInitialize).toHaveBeenCalledTimes(1);
});

it('does not update state after unmount (cancellation)', async () => {
// Simulate a slow render that resolves after the component unmounts
let resolveRender: (v: unknown) => void;
const renderPromise = new Promise((resolve) => {
resolveRender = resolve;
});
mockRender.mockReturnValue(renderPromise);

const MermaidBlock = await freshMermaidBlock();
const { unmount } = render(createElement(MermaidBlock, { code: 'graph TD; A-->B;' }));

// Unmount before render resolves — sets cancelled = true
unmount();

// Now resolve the render — the cancelled flag should prevent setSvg
await act(async () => {
resolveRender!({
svg: '<svg>late</svg>',
diagramType: 'flowchart',
bindFunctions: undefined,
});
});

// No SVG should appear in the document (component is unmounted and
// the state update was skipped)
expect(document.querySelector('.mermaid-block-svg')).toBeNull();
});
});
Loading
Loading