Skip to content

Commit f6e6b70

Browse files
authored
fix(ui): stabilize mermaid diagram rendering while streaming (#1676)
Signed-off-by: Petr Kadlec <[email protected]>
1 parent eb3ca00 commit f6e6b70

File tree

10 files changed

+153
-33
lines changed

10 files changed

+153
-33
lines changed

apps/agentstack-ui/src/components/MarkdownContent/MarkdownContent.tsx

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@ import type { PluggableList } from 'unified';
1414
import { components, type ExtendedComponents } from './components';
1515
import { Code } from './components/Code';
1616
import { MermaidDiagram } from './components/MermaidDiagram';
17+
import { MermaidProvider } from './contexts';
1718
import classes from './MarkdownContent.module.scss';
1819
import { rehypePlugins } from './rehype';
1920
import { remarkPlugins } from './remark';
2021
import { urlTransform } from './utils';
2122

2223
export interface MarkdownContentProps {
2324
codeBlocksExpanded?: boolean;
24-
showMermaidDiagrams?: boolean;
25+
isStreaming?: boolean;
2526
children?: string;
2627
className?: string;
2728
remarkPlugins?: PluggableList;
@@ -30,7 +31,7 @@ export interface MarkdownContentProps {
3031

3132
export function MarkdownContent({
3233
codeBlocksExpanded,
33-
showMermaidDiagrams,
34+
isStreaming,
3435
className,
3536
remarkPlugins: remarkPluginsProps,
3637
components: componentsProps,
@@ -40,24 +41,26 @@ export function MarkdownContent({
4041
() => ({
4142
...components,
4243
code: ({ ...props }) => <Code {...props} forceExpand={codeBlocksExpanded} />,
43-
mermaidDiagram: ({ ...props }) => <MermaidDiagram {...props} showDiagram={showMermaidDiagrams} />,
44+
mermaidDiagram: (props) => <MermaidDiagram {...props} isStreaming={isStreaming} />,
4445
...componentsProps,
4546
}),
46-
[codeBlocksExpanded, showMermaidDiagrams, componentsProps],
47+
[codeBlocksExpanded, componentsProps, isStreaming],
4748
);
4849

4950
const extendedRemarkPlugins = useMemo(() => [...remarkPlugins, ...(remarkPluginsProps ?? [])], [remarkPluginsProps]);
5051

5152
return (
52-
<div className={clsx(classes.root, className)}>
53-
<Markdown
54-
rehypePlugins={rehypePlugins}
55-
remarkPlugins={extendedRemarkPlugins}
56-
components={extendedComponents}
57-
urlTransform={urlTransform}
58-
>
59-
{children}
60-
</Markdown>
61-
</div>
53+
<MermaidProvider>
54+
<div className={clsx(classes.root, className)}>
55+
<Markdown
56+
rehypePlugins={rehypePlugins}
57+
remarkPlugins={extendedRemarkPlugins}
58+
components={extendedComponents}
59+
urlTransform={urlTransform}
60+
>
61+
{children}
62+
</Markdown>
63+
</div>
64+
</MermaidProvider>
6265
);
6366
}

apps/agentstack-ui/src/components/MarkdownContent/components/MermaidDiagram.module.scss

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,20 @@
1212
.diagram {
1313
text-align: center;
1414
}
15+
16+
.loading {
17+
display: flex;
18+
justify-content: center;
19+
align-items: center;
20+
min-block-size: rem(120px);
21+
22+
:global(.cds--inline-loading) {
23+
justify-content: center;
24+
}
25+
}
26+
27+
.error {
28+
:global(.cds--inline-notification__subtitle) {
29+
@include line-clamp(2);
30+
}
31+
}

apps/agentstack-ui/src/components/MarkdownContent/components/MermaidDiagram.tsx

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,48 +3,59 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import { InlineLoading, InlineNotification } from '@carbon/react';
67
import mermaid from 'mermaid';
7-
import { type HTMLAttributes, useEffect, useId, useState } from 'react';
8+
import { type HTMLAttributes, useEffect, useId } from 'react';
89
import type { ExtraProps } from 'react-markdown';
910

1011
import { useTheme } from '#contexts/Theme/index.ts';
1112
import { Theme } from '#contexts/Theme/types.ts';
1213

14+
import { useMermaid } from '../contexts';
1315
import { Code } from './Code';
1416
import classes from './MermaidDiagram.module.scss';
1517

1618
export type MermaidDiagramProps = HTMLAttributes<HTMLElement> &
17-
ExtraProps & {
18-
showDiagram?: boolean;
19-
};
19+
ExtraProps & { mermaidIndex?: number; isStreaming?: boolean };
2020

21-
export function MermaidDiagram({ showDiagram = true, children }: MermaidDiagramProps) {
21+
export function MermaidDiagram({ children, mermaidIndex, isStreaming }: MermaidDiagramProps) {
2222
const id = useId();
23-
const [diagram, setDiagram] = useState<string | null>(null);
24-
2523
const { theme } = useTheme();
24+
const { diagrams, setDiagram } = useMermaid();
25+
26+
if (mermaidIndex === undefined) {
27+
console.error('MermaidDiagram component requires a `mermaidIndex` prop.');
28+
}
29+
const index = mermaidIndex ?? 0;
30+
31+
const diagram = diagrams.get(index);
2632

2733
useEffect(() => {
28-
mermaid.initialize({ startOnLoad: false, theme: theme === Theme.Dark ? 'dark' : 'default' });
34+
mermaid.initialize({
35+
startOnLoad: false,
36+
theme: theme === Theme.Dark ? 'dark' : 'default',
37+
suppressErrorRendering: true,
38+
});
2939
}, [theme]);
3040

3141
useEffect(() => {
3242
let isMounted = true;
3343

3444
async function renderDiagram() {
35-
if (!showDiagram || typeof children !== 'string') {
45+
if (typeof children !== 'string') {
3646
return;
3747
}
3848

3949
try {
4050
const { svg } = await mermaid.render(id, children);
4151

4252
if (isMounted) {
43-
setDiagram(svg);
53+
setDiagram(index, svg);
4454
}
4555
} catch (error) {
46-
if (isMounted) {
56+
if (isMounted && !isStreaming) {
4757
console.warn(error);
58+
setDiagram(index, error instanceof Error ? error : new Error('Unknown error rendering Mermaid diagram'));
4859
}
4960
}
5061
}
@@ -54,13 +65,27 @@ export function MermaidDiagram({ showDiagram = true, children }: MermaidDiagramP
5465
return () => {
5566
isMounted = false;
5667
};
57-
}, [showDiagram, children, theme, id]);
68+
}, [children, theme, id, setDiagram, index, isStreaming]);
5869

5970
return (
6071
<div className={classes.root}>
6172
<Code className="language-mermaid">{children}</Code>
6273

63-
{showDiagram && diagram && <div dangerouslySetInnerHTML={{ __html: diagram }} className={classes.diagram} />}
74+
{typeof diagram === 'string' ? (
75+
<div dangerouslySetInnerHTML={{ __html: diagram }} className={classes.diagram} />
76+
) : diagram instanceof Error && !isStreaming ? (
77+
<InlineNotification
78+
kind="error"
79+
title={'Failed to render Mermaid diagram'}
80+
subtitle={diagram.message}
81+
lowContrast
82+
className={classes.error}
83+
/>
84+
) : (
85+
<div className={classes.loading}>
86+
<InlineLoading description="Rendering diagram..." />
87+
</div>
88+
)}
6489
</div>
6590
);
6691
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import type { PropsWithChildren } from 'react';
7+
import { useCallback, useMemo, useState } from 'react';
8+
9+
import type { MermaidContextValue } from './mermaid-context';
10+
import { MermaidContext } from './mermaid-context';
11+
12+
export function MermaidProvider({ children }: PropsWithChildren) {
13+
const [diagrams, setDiagrams] = useState<Map<number, string | Error>>(new Map());
14+
15+
const setDiagram = useCallback((index: number, svg: string) => {
16+
setDiagrams((prev) => {
17+
const newMap = new Map(prev);
18+
newMap.set(index, svg);
19+
return newMap;
20+
});
21+
}, []);
22+
23+
const value = useMemo<MermaidContextValue>(
24+
() => ({
25+
diagrams,
26+
setDiagram,
27+
}),
28+
[diagrams, setDiagram],
29+
);
30+
31+
return <MermaidContext.Provider value={value}>{children}</MermaidContext.Provider>;
32+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
export { MermaidProvider } from './MermaidProvider';
7+
export { useMermaid } from './useMermaid';
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
'use client';
6+
import { createContext } from 'react';
7+
8+
export const MermaidContext = createContext<MermaidContextValue | null>(null);
9+
10+
export interface MermaidContextValue {
11+
diagrams: Map<number, string | Error>;
12+
setDiagram: (index: number, value: string | Error) => void;
13+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
'use client';
6+
7+
import { useContext } from 'react';
8+
9+
import { MermaidContext } from './mermaid-context';
10+
11+
export function useMermaid() {
12+
const context = useContext(MermaidContext);
13+
14+
if (!context) {
15+
throw new Error('useMermaid must be used within MermaidProvider');
16+
}
17+
18+
return context;
19+
}

apps/agentstack-ui/src/components/MarkdownContent/remark/remarkMermaid.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,19 @@ import { visit } from 'unist-util-visit';
99

1010
export function remarkMermaid() {
1111
return (tree: Root) => {
12+
let mermaidIndex = 0;
13+
1214
visit(tree, 'code', (node: Code) => {
1315
if (node.lang === 'mermaid') {
1416
node.data = {
1517
...node.data,
1618
hName: 'mermaidDiagram',
19+
hProperties: {
20+
mermaidIndex,
21+
},
1722
};
23+
24+
mermaidIndex++;
1825
}
1926
});
2027
};

apps/agentstack-ui/src/modules/messages/components/MessageContent.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { memo } from 'react';
99
import type { UIMessage } from '#modules/messages/types.ts';
1010
import { ChatMarkdownContent } from '#modules/runs/components/ChatMarkdownContent/ChatMarkdownContent.tsx';
1111

12-
import { useAgentRun } from '../../runs/contexts/agent-run';
1312
import { Role } from '../api/types';
1413
import { checkMessageStatus, getMessageContent, getMessageSecret, getMessageSources, isAgentMessage } from '../utils';
1514
import classes from './MessageContent.module.scss';
@@ -20,8 +19,6 @@ interface Props {
2019
}
2120

2221
export const MessageContent = memo(function MessageContent({ message }: Props) {
23-
const { isPending } = useAgentRun();
24-
2522
const content = getMessageContent(message);
2623
const form = message.role === Role.User ? message.form : null;
2724
const auth = message.role === Role.User ? message.auth : null;
@@ -45,8 +42,8 @@ export const MessageContent = memo(function MessageContent({ message }: Props) {
4542
<ChatMarkdownContent
4643
className={classes.root}
4744
sources={sources}
48-
codeBlocksExpanded={isPending}
49-
showMermaidDiagrams={!isPending}
45+
codeBlocksExpanded={status?.isInProgress}
46+
isStreaming={status?.isInProgress}
5047
>
5148
{content}
5249
</ChatMarkdownContent>

apps/agentstack-ui/src/modules/runs/components/RunOutputBox.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function RunOutputBox({ isPending, text, downloadFileName, sources, child
3131
)}
3232

3333
{text && (
34-
<ChatMarkdownContent sources={sources} codeBlocksExpanded={isPending} showMermaidDiagrams={!isPending}>
34+
<ChatMarkdownContent sources={sources} codeBlocksExpanded={isPending} isStreaming={isPending}>
3535
{text}
3636
</ChatMarkdownContent>
3737
)}

0 commit comments

Comments
 (0)