-
Notifications
You must be signed in to change notification settings - Fork 0
feat(frontend): mermaid diagrams + preview card auto-close fix #434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
08f47f8
01e46b8
f4aebb4
eb12ac1
b754ab4
4596532
4c633a6
946f7ea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 unsafe_assumptions: The module-level
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 style: Module-level
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 unsafe_assumptions: Module-level
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 unsafe_assumptions: The module-level
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 unsafe_assumptions: The module-level |
||
|
|
||
| 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; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 bugs: |
||
| 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) { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 unsafe_assumptions: The DOM cleanup |
||
| 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; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 style: When |
||
|
|
||
| return ( | ||
| <div className="mermaid-block"> | ||
| <div className="mermaid-block-svg" dangerouslySetInnerHTML={{ __html: svg }} /> | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 unsafe_assumptions:
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 unsafe_assumptions: Using |
||
| <CopyButton text={code} className="code-block-copy" label="Copy source" /> | ||
| </div> | ||
| ); | ||
| } | ||
| 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'; | ||
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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); | ||
|
|
@@ -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'>) => { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 style: The mermaid detection logic (extracting the first child, checking className against
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 style: The mermaid-aware ). Consider extracting the shared mermaid detection + fallback pattern, or having MessageBubble compose on top of markdown-config's pre.
|
||
| 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' : ''}`} | ||
|
|
@@ -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> | ||
|
|
||
| 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(); | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: |
||
| 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(); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
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
mermaidInitializedflag 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.