feat: render mermaid diagrams in chat code blocks (#9738)

* feat: render mermaid diagrams in chat code blocks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: guard mermaid render against out-of-order async and transient streaming failures

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: only show mermaid diagram while it matches current source

Addresses Codex review: keeping the last good SVG through parse failures left a stale, mismatched diagram on screen when the source changed to something invalid. Tie the rendered SVG to the source that produced it and only display it while it still matches the current code, falling back to the raw source otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-06-23 23:02:10 +02:00
committed by GitHub
parent cbf54d4eb4
commit cfb9f1dbc2
4 changed files with 766 additions and 85 deletions
+689 -76
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -127,6 +127,7 @@
"lru-cache": "^11.1.0",
"lucide-svelte": "^0.540.0",
"mdast-util-find-and-replace": "^3.0.2",
"mermaid": "^11.15.0",
"minimatch": "^10.0.1",
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
"monaco-languageclient": "10.6.0",
@@ -17,6 +17,7 @@
import { AIMode } from '../AIChatManager.svelte'
import { getAiChatManager } from '../aiChatManagerContext'
import { Check, Play } from 'lucide-svelte'
import MermaidDisplay from './MermaidDisplay.svelte'
const aiChatManager = getAiChatManager()
@@ -108,14 +109,18 @@
<div
class="relative w-full border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden"
>
<HighlightCode
className="p-1"
code={code ?? ''}
highlightLanguage={SMART_LANG_TO_HIGHLIGHT_LANG[getSmartLang(language as string)]}
language={undefined}
onApplyCode={handleApplyCode}
{showApplyButton}
applyButtonIcon={aiChatManager.pendingNewCode ? Check : Play}
/>
{#if language === 'mermaid'}
<MermaidDisplay code={code ?? ''} />
{:else}
<HighlightCode
className="p-1"
code={code ?? ''}
highlightLanguage={SMART_LANG_TO_HIGHLIGHT_LANG[getSmartLang(language as string)]}
language={undefined}
onApplyCode={handleApplyCode}
{showApplyButton}
applyButtonIcon={aiChatManager.pendingNewCode ? Check : Play}
/>
{/if}
</div>
</div>
@@ -0,0 +1,62 @@
<script lang="ts">
import { randomUUID } from '$lib/utils/uuid'
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
let { code }: { code: string } = $props()
const isDarkMode = useIsDarkMode()
let svg = $state<string | undefined>(undefined)
// The exact source that produced `svg`. The diagram is only shown while this
// still matches the current `code`, so a later edit that fails to parse falls
// back to the raw source instead of leaving a stale, mismatched diagram.
let renderedCode = $state<string | undefined>(undefined)
// Monotonic token so an earlier-started render that resolves late can't
// overwrite the result of a newer one (out-of-order async on rapid code/theme changes).
let renderSeq = 0
async function render(source: string, dark: boolean) {
const seq = ++renderSeq
if (!source?.trim()) {
svg = undefined
renderedCode = undefined
return
}
try {
const mermaid = (await import('mermaid')).default
mermaid.initialize({
startOnLoad: false,
theme: dark ? 'dark' : 'default',
securityLevel: 'strict',
// Throw on parse errors instead of injecting an orphan error diagram into the DOM.
suppressErrorRendering: true
})
// mermaid.render needs a fresh element id per attempt to avoid id collisions.
const result = await mermaid.render(`mermaid-${randomUUID()}`, source)
if (seq !== renderSeq) return
svg = result.svg
renderedCode = source
} catch {
// Parse failure (often a partial block still streaming in): fall back to the
// raw source. `showSvg` already hides any previous diagram since `renderedCode`
// no longer matches the current `code`.
}
}
$effect(() => {
void render(code, isDarkMode.val)
})
// Only show the diagram while it corresponds to the current source.
let showSvg = $derived(svg !== undefined && renderedCode === code)
</script>
{#if showSvg}
<div class="p-2 flex justify-center overflow-x-auto">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html svg}
</div>
{:else}
<!-- Fallback while loading or when rendering fails: show the raw source -->
<pre class="overflow-auto max-h-screen text-xs p-2">{code}</pre>
{/if}