feat(chat): add Mermaid diagram zoom controls (#2100)

* feat(chat): add mermaid diagram zoom controls

* fix(chat): handle malformed mermaid data urls

* fix(chat): preserve mermaid load error stack

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Carson
2026-07-08 20:43:23 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent dfed121bf1
commit 3d32ac7989
18 changed files with 1069 additions and 115 deletions
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'bun:test';
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
describe('getMermaidDataUrlSourcePromise', () => {
test('turns malformed data URLs into rejected promises', async () => {
const sourcePromise = getMermaidDataUrlSourcePromise('data:text/plain;base64');
await sourcePromise.then(
() => {
throw new Error('expected malformed data URL to reject');
},
(error) => {
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(MermaidLoadFailure);
expect(error.key).toBe('chat.toolOutputDialog.mermaid.dataUrlMalformed');
expect(error.params).toBe(undefined);
},
);
});
});
describe('Mermaid load request ids', () => {
test('invalidates stale async loads when a newer load starts', () => {
const firstRequest = nextMermaidLoadRequestId(0);
const secondRequest = nextMermaidLoadRequestId(firstRequest);
expect(isCurrentMermaidLoadRequest(secondRequest, firstRequest)).toBe(false);
expect(isCurrentMermaidLoadRequest(secondRequest, secondRequest)).toBe(true);
});
});
@@ -26,8 +26,9 @@ import { DiffViewToggle } from './DiffViewToggle';
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { Icon } from "@/components/icon/Icon";
import { useI18n } from '@/lib/i18n';
import { useI18n, type I18nKey, type I18nParams } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { MermaidLoadFailure, getMermaidDataUrlSourcePromise, isCurrentMermaidLoadRequest, isMermaidLoadFailure, nextMermaidLoadRequestId } from './toolOutputDialogMermaid';
interface ToolOutputDialogProps {
popup: ToolPopupContent;
@@ -35,6 +36,8 @@ interface ToolOutputDialogProps {
isMobile: boolean;
}
const mermaidLoadFailure = (key: I18nKey, params?: I18nParams): MermaidLoadFailure => new MermaidLoadFailure(key, params);
const getToolIcon = (toolName: string) => {
const iconClass = 'h-3.5 w-3.5 flex-shrink-0';
const tool = toolName.toLowerCase();
@@ -97,7 +100,7 @@ const MERMAID_ASPECT_MAX_RETRIES = 3;
const DIALOG_CODE_TAG_PROPS = { style: { background: 'transparent', backgroundColor: 'transparent', fontSize: 'inherit' } };
const MERMAID_CONTROLS = { download: false, copy: false, fullscreen: false, panZoom: true };
const MERMAID_CONTROLS = { download: false, copy: false, showPanZoomControls: true };
type PierreThemeConfig = {
theme: { light: string; dark: string };
@@ -694,22 +697,11 @@ const MermaidPreviewDialog: React.FC<{
return isSafeLocalPath(decoded) ? decoded : (isSafeLocalPath(stripped) ? stripped : null);
}, []);
const decodeDataUrl = React.useCallback((value: string): string => {
const commaIndex = value.indexOf(',');
if (commaIndex < 0) {
throw new Error('Malformed data URL');
}
const metadata = value.slice(0, commaIndex).toLowerCase();
const payload = value.slice(commaIndex + 1);
if (metadata.includes(';base64')) {
return atob(payload);
}
return decodeURIComponent(payload);
}, []);
const loadMermaidSource = React.useCallback(async () => {
const target = popup.mermaid;
const requestId = nextMermaidLoadRequestId(requestIdRef.current);
requestIdRef.current = requestId;
if (!target?.url) {
setStatus('error');
setErrorMessage(t('chat.toolOutputDialog.mermaid.missingSource'));
@@ -723,24 +715,21 @@ const MermaidPreviewDialog: React.FC<{
return;
}
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
setStatus('loading');
setErrorMessage('');
let sourcePromise: Promise<string>;
if (target.url.startsWith('data:')) {
sourcePromise = Promise.resolve(decodeDataUrl(target.url));
sourcePromise = getMermaidDataUrlSourcePromise(target.url);
} else if (target.url.toLowerCase().startsWith('file://')) {
const normalizedPath = normalizeFilePath(target.url);
if (!normalizedPath) {
sourcePromise = Promise.reject(new Error('Invalid local file path for Mermaid preview.'));
sourcePromise = Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.invalidLocalPath'));
} else {
sourcePromise = runtimeFetch('/api/fs/raw', { query: { path: normalizedPath } })
.then((response) => {
if (!response.ok) {
return Promise.reject(new Error(`Failed to read diagram file (${response.status})`));
return Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.readFileFailedWithStatus', { status: response.status }));
}
return response.text();
});
@@ -752,12 +741,12 @@ const MermaidPreviewDialog: React.FC<{
const resolvedUrl = canParse ? new URL(target.url, window.location.origin) : null;
if (!resolvedUrl || (resolvedUrl.protocol !== 'http:' && resolvedUrl.protocol !== 'https:')) {
sourcePromise = Promise.reject(new Error('Unsupported Mermaid URL protocol.'));
sourcePromise = Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.unsupportedUrlProtocol'));
} else {
sourcePromise = fetch(resolvedUrl.toString())
.then((response) => {
if (!response.ok) {
return Promise.reject(new Error(`Failed to load diagram (${response.status})`));
return Promise.reject(mermaidLoadFailure('chat.toolOutputDialog.mermaid.loadFailedWithStatus', { status: response.status }));
}
return response.text();
});
@@ -766,7 +755,7 @@ const MermaidPreviewDialog: React.FC<{
await sourcePromise
.then((resolvedSource) => {
if (requestIdRef.current !== requestId) {
if (!isCurrentMermaidLoadRequest(requestIdRef.current, requestId)) {
return;
}
@@ -774,13 +763,13 @@ const MermaidPreviewDialog: React.FC<{
setStatus('ready');
})
.catch((error) => {
if (requestIdRef.current !== requestId) {
if (!isCurrentMermaidLoadRequest(requestIdRef.current, requestId)) {
return;
}
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : t('chat.toolOutputDialog.mermaid.loadFailed'));
setErrorMessage(isMermaidLoadFailure(error) ? t(error.key, error.params) : t('chat.toolOutputDialog.mermaid.loadFailed'));
});
}, [decodeDataUrl, normalizeFilePath, popup.mermaid, t]);
}, [normalizeFilePath, popup.mermaid, t]);
React.useEffect(() => {
if (!popup.open || !popup.mermaid) {
@@ -896,10 +885,11 @@ const MermaidPreviewDialog: React.FC<{
<div
aria-hidden="true"
className={cn(
'absolute inset-0 bg-black/40',
'absolute inset-0',
isTransitioning && 'transition-opacity duration-150 ease-out',
isVisible ? 'opacity-100' : 'opacity-0'
)}
style={{ backgroundColor: 'color-mix(in srgb, var(--surface-background) 70%, transparent)' }}
onMouseDown={() => onOpenChange(false)}
/>
@@ -941,7 +931,13 @@ const MermaidPreviewDialog: React.FC<{
)}
{status === 'error' && (
<div className="rounded-xl border border-border/30 bg-muted/20 p-3 space-y-3">
<div
className="rounded-xl border p-3 space-y-3"
style={{
backgroundColor: 'var(--status-error-background)',
borderColor: 'var(--status-error-border)',
}}
>
<p className="typography-markdown" style={{ color: 'var(--status-error)' }}>
{errorMessage || t('chat.toolOutputDialog.mermaid.renderFailed')}
</p>
@@ -966,8 +962,8 @@ const MermaidPreviewDialog: React.FC<{
<SimpleMarkdownRenderer
content={mermaidMarkdown}
variant="tool"
allowMermaidWheelZoom
className="markdown-mermaid-fullscreen h-full [&_[data-markdown='mermaid-block']_button]:hidden"
allowMermaidWheelEvents
className="markdown-mermaid-fullscreen h-full"
mermaidControls={MERMAID_CONTROLS}
enableFileReferences={false}
/>
@@ -0,0 +1,37 @@
import type { I18nKey, I18nParams } from '@/lib/i18n';
export class MermaidLoadFailure extends Error {
key: I18nKey;
params?: I18nParams;
constructor(key: I18nKey, params?: I18nParams) {
super(key);
this.name = 'MermaidLoadFailure';
this.key = key;
this.params = params;
}
}
const mermaidLoadFailure = (key: I18nKey, params?: I18nParams): MermaidLoadFailure => new MermaidLoadFailure(key, params);
export const isMermaidLoadFailure = (value: unknown): value is MermaidLoadFailure => value instanceof MermaidLoadFailure;
export const nextMermaidLoadRequestId = (current: number): number => current + 1;
export const isCurrentMermaidLoadRequest = (current: number, requestId: number): boolean => current === requestId;
const decodeMermaidDataUrl = (value: string): string => {
const commaIndex = value.indexOf(',');
if (commaIndex < 0) {
throw mermaidLoadFailure('chat.toolOutputDialog.mermaid.dataUrlMalformed');
}
const metadata = value.slice(0, commaIndex).toLowerCase();
const payload = value.slice(commaIndex + 1);
if (metadata.includes(';base64')) {
return atob(payload);
}
return decodeURIComponent(payload);
};
export const getMermaidDataUrlSourcePromise = (value: string): Promise<string> => Promise.resolve().then(() => decodeMermaidDataUrl(value));